mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 15:58:13 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dc0feb374 | |||
| 33d8705431 | |||
| e7db44f45d | |||
| 126e138382 | |||
| 2efb16e248 | |||
| d494c811a0 | |||
| e800ac0ed1 | |||
| 26ae2ef515 | |||
| 4486697ca5 | |||
| 94ad142edf | |||
| b8b0b6b0fa | |||
| 8c72a7fb75 | |||
| 7df74cd7e8 | |||
| 8f81c7ffea | |||
| f6fe310e3e | |||
| a59caa41da | |||
| 7d4c7f51e3 | |||
| 8063ae18a8 | |||
| b8056c6d79 | |||
| b1b6e02a19 | |||
| 65447b927a | |||
| bde726fedf | |||
| 27cca273d5 | |||
| c1a1d64928 | |||
| 948bd06a3e | |||
| c4f7b0ccbc | |||
| f5deb0316b | |||
| 80553c6450 | |||
| 7b31490488 | |||
| 7419d5a14b | |||
| 4864185183 | |||
| 7aa5aeb4f8 | |||
| b4bdd13743 | |||
| 62b76769d0 | |||
| 31b3aca8f9 | |||
| 61dae6c605 | |||
| b49c634f17 | |||
| 5910961825 | |||
| 7a4e187745 | |||
| ebfdad8898 | |||
| c3db42bd76 | |||
| d36f8915ac | |||
| 6c6a02bcac | |||
| 42261b325b | |||
| 09e485572a | |||
| 491e083bb4 | |||
| 7f636a451c | |||
| 4656d1bce9 | |||
| 983e6e5fa9 | |||
| 244752c343 | |||
| f6e46d4a46 | |||
| 067e00b474 | |||
| b747e4dee2 | |||
| 3d88d1628a | |||
| 0553479055 | |||
| 2d52bdd714 |
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 364 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 289 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 344 KiB |
@@ -0,0 +1,115 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_LOGIN }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_REGISTRY }}
|
||||
tags: |
|
||||
type=semver,pattern={{raw}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: VERSION=${{ steps.meta.outputs.version }}
|
||||
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ secrets.DOCKER_REGISTRY }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ strategy.job-index }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_LOGIN }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_REGISTRY }}
|
||||
tags: |
|
||||
type=semver,pattern={{raw}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ secrets.DOCKER_REGISTRY }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ secrets.DOCKER_REGISTRY }}:${{ steps.meta.outputs.version }}
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_LOGIN }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_REGISTRY }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Release Charts
|
||||
|
||||
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
-1
@@ -59,4 +59,5 @@ data/
|
||||
meta/
|
||||
garage.toml
|
||||
|
||||
backend/docs/
|
||||
backend/docs/
|
||||
config.yaml
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
<!-- omit in toc -->
|
||||
# Contributing to Garage UI
|
||||
|
||||
First off, thanks for taking the time to contribute! ❤️
|
||||
|
||||
All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
|
||||
|
||||
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
|
||||
> - Star the project
|
||||
> - Tweet about it
|
||||
> - Refer this project in your project's readme
|
||||
> - Mention the project at local meetups and tell your friends/colleagues
|
||||
|
||||
<!-- omit in toc -->
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [I Have a Question](#i-have-a-question)
|
||||
- [I Want To Contribute](#i-want-to-contribute)
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Suggesting Enhancements](#suggesting-enhancements)
|
||||
- [Your First Code Contribution](#your-first-code-contribution)
|
||||
- [Improving The Documentation](#improving-the-documentation)
|
||||
- [Styleguides](#styleguides)
|
||||
- [Commit Messages](#commit-messages)
|
||||
- [Join The Project Team](#join-the-project-team)
|
||||
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project and everyone participating in it is governed by the
|
||||
[Garage UI Code of Conduct](https://github.com/Noooste/garage-ui/blob//CODE_OF_CONDUCT.md).
|
||||
By participating, you are expected to uphold this code. Please report unacceptable behavior
|
||||
to .
|
||||
|
||||
|
||||
## I Have a Question
|
||||
|
||||
> If you want to ask a question, we assume that you have read the available [Documentation]().
|
||||
|
||||
Before you ask a question, it is best to search for existing [Issues](https://github.com/Noooste/garage-ui/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first.
|
||||
|
||||
If you then still feel the need to ask a question and need clarification, we recommend the following:
|
||||
|
||||
- Open an [Issue](https://github.com/Noooste/garage-ui/issues/new).
|
||||
- Provide as much context as you can about what you're running into.
|
||||
- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant.
|
||||
|
||||
We will then take care of the issue as soon as possible.
|
||||
|
||||
<!--
|
||||
You might want to create a separate issue tag for questions and include it in this description. People should then tag their issues accordingly.
|
||||
|
||||
Depending on how large the project is, you may want to outsource the questioning, e.g. to Stack Overflow or Gitter. You may add additional contact and information possibilities:
|
||||
- IRC
|
||||
- Slack
|
||||
- Gitter
|
||||
- Stack Overflow tag
|
||||
- Blog
|
||||
- FAQ
|
||||
- Roadmap
|
||||
- E-Mail List
|
||||
- Forum
|
||||
-->
|
||||
|
||||
## I Want To Contribute
|
||||
|
||||
> ### Legal Notice <!-- omit in toc -->
|
||||
> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project licence.
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
<!-- omit in toc -->
|
||||
#### Before Submitting a Bug Report
|
||||
|
||||
A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.
|
||||
|
||||
- Make sure that you are using the latest version.
|
||||
- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](). If you are looking for support, you might want to check [this section](#i-have-a-question)).
|
||||
- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/Noooste/garage-ui/issues?q=label%3Abug).
|
||||
- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue.
|
||||
- Collect information about the bug:
|
||||
- Stack trace (Traceback)
|
||||
- OS, Platform and Version (Windows, Linux, macOS, x86, ARM)
|
||||
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant.
|
||||
- Possibly your input and the output
|
||||
- Can you reliably reproduce the issue? And can you also reproduce it with older versions?
|
||||
|
||||
<!-- omit in toc -->
|
||||
#### How Do I Submit a Good Bug Report?
|
||||
|
||||
> You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to .
|
||||
<!-- You may add a PGP key to allow the messages to be sent encrypted as well. -->
|
||||
|
||||
We use GitHub issues to track bugs and errors. If you run into an issue with the project:
|
||||
|
||||
- Open an [Issue](https://github.com/Noooste/garage-ui/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.)
|
||||
- Explain the behavior you would expect and the actual behavior.
|
||||
- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case.
|
||||
- Provide the information you collected in the previous section.
|
||||
|
||||
Once it's filed:
|
||||
|
||||
- The project team will label the issue accordingly.
|
||||
- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
|
||||
- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution).
|
||||
|
||||
<!-- You might want to create an issue template for bugs and errors that can be used as a guide and that defines the structure of the information to be included. If you do so, reference it here in the description. -->
|
||||
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
This section guides you through submitting an enhancement suggestion for Garage UI, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
|
||||
|
||||
<!-- omit in toc -->
|
||||
#### Before Submitting an Enhancement
|
||||
|
||||
- Make sure that you are using the latest version.
|
||||
- Read the [documentation]() carefully and find out if the functionality is already covered, maybe by an individual configuration.
|
||||
- Perform a [search](https://github.com/Noooste/garage-ui/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
|
||||
- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
|
||||
|
||||
<!-- omit in toc -->
|
||||
#### How Do I Submit a Good Enhancement Suggestion?
|
||||
|
||||
Enhancement suggestions are tracked as [GitHub issues](https://github.com/Noooste/garage-ui/issues).
|
||||
|
||||
- Use a **clear and descriptive title** for the issue to identify the suggestion.
|
||||
- Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
|
||||
- **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.
|
||||
+21
-11
@@ -1,34 +1,44 @@
|
||||
FROM node:25-alpine3.22 AS frontend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:25-alpine3.23 AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
|
||||
RUN npm ci
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install
|
||||
|
||||
COPY frontend/ .
|
||||
|
||||
COPY frontend/.env.production .env
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.25.4-alpine3.22 AS backend-builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.0-alpine3.23 AS backend-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN go install github.com/swaggo/swag/cmd/swag@latest
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY backend .
|
||||
|
||||
RUN swag init
|
||||
ARG VERSION=dev
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o garage-ui .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
swag init
|
||||
|
||||
FROM alpine:3.22
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -ldflags "-X main.version=${VERSION}" -o garage-ui .
|
||||
|
||||
FROM alpine:3.23.3
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -45,7 +55,7 @@ USER garageui
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
CMD ["./garage-ui"]
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
.PHONY: help build up down restart logs clean ps health dev-build dev-up dev-down dev-restart dev-logs prod-up prod-down prod-restart prod-logs push stop
|
||||
|
||||
# Variables
|
||||
DOCKER_COMPOSE = docker-compose
|
||||
DOCKER_COMPOSE_DEV = docker-compose -f docker-compose.dev.yml
|
||||
DOCKER_COMPOSE_PROD = docker-compose -f docker-compose.yml
|
||||
IMAGE_NAME = noooste/garage-ui
|
||||
IMAGE_TAG = latest
|
||||
|
||||
## help: Show this help message
|
||||
help:
|
||||
@echo 'Usage:'
|
||||
@sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /'
|
||||
|
||||
## build: Build the Docker image locally
|
||||
build:
|
||||
docker build -t $(IMAGE_NAME):$(IMAGE_TAG) .
|
||||
|
||||
## build-no-cache: Build the Docker image without cache
|
||||
build-no-cache:
|
||||
docker build --no-cache -t $(IMAGE_NAME):$(IMAGE_TAG) .
|
||||
|
||||
## push: Push the Docker image to registry
|
||||
push: build
|
||||
docker push $(IMAGE_NAME):$(IMAGE_TAG)
|
||||
|
||||
## dev-build: Build and start development environment
|
||||
dev-build:
|
||||
$(DOCKER_COMPOSE_DEV) build
|
||||
|
||||
## dev-up: Start development environment
|
||||
dev-up:
|
||||
$(DOCKER_COMPOSE_DEV) up -d
|
||||
|
||||
## dev-down: Stop development environment
|
||||
dev-down:
|
||||
$(DOCKER_COMPOSE_DEV) down
|
||||
|
||||
## dev-restart: Restart development environment
|
||||
dev-restart: dev-down dev-up
|
||||
|
||||
## dev-logs: Show logs for development environment
|
||||
dev-logs:
|
||||
$(DOCKER_COMPOSE_DEV) logs -f
|
||||
|
||||
## dev-logs-ui: Show logs for garage-ui in development
|
||||
dev-logs-ui:
|
||||
$(DOCKER_COMPOSE_DEV) logs -f garage-ui
|
||||
|
||||
## dev-logs-garage: Show logs for garage in development
|
||||
dev-logs-garage:
|
||||
$(DOCKER_COMPOSE_DEV) logs -f garage
|
||||
|
||||
## dev-shell: Open shell in garage-ui container (development)
|
||||
dev-shell:
|
||||
$(DOCKER_COMPOSE_DEV) exec garage-ui sh
|
||||
|
||||
## prod-up: Start production environment
|
||||
prod-up:
|
||||
$(DOCKER_COMPOSE_PROD) up -d
|
||||
|
||||
## prod-down: Stop production environment
|
||||
prod-down:
|
||||
$(DOCKER_COMPOSE_PROD) down
|
||||
|
||||
## prod-restart: Restart production environment
|
||||
prod-restart: prod-down prod-up
|
||||
|
||||
## prod-logs: Show logs for production environment
|
||||
prod-logs:
|
||||
$(DOCKER_COMPOSE_PROD) logs -f
|
||||
|
||||
## prod-logs-ui: Show logs for garage-ui in production
|
||||
prod-logs-ui:
|
||||
$(DOCKER_COMPOSE_PROD) logs -f garage-ui
|
||||
|
||||
## prod-logs-garage: Show logs for garage in production
|
||||
prod-logs-garage:
|
||||
$(DOCKER_COMPOSE_PROD) logs -f garage
|
||||
|
||||
## prod-pull: Pull latest images for production
|
||||
prod-pull:
|
||||
$(DOCKER_COMPOSE_PROD) pull
|
||||
|
||||
## up: Start default (production) environment
|
||||
up: prod-up
|
||||
|
||||
## down: Stop all environments
|
||||
down:
|
||||
$(DOCKER_COMPOSE_PROD) down
|
||||
$(DOCKER_COMPOSE_DEV) down
|
||||
|
||||
## stop: Stop all containers
|
||||
stop:
|
||||
$(DOCKER_COMPOSE_PROD) stop
|
||||
$(DOCKER_COMPOSE_DEV) stop
|
||||
|
||||
## restart: Restart default (production) environment
|
||||
restart: prod-restart
|
||||
|
||||
## logs: Show logs for default (production) environment
|
||||
logs: prod-logs
|
||||
|
||||
## ps: Show running containers
|
||||
ps:
|
||||
docker ps -a --filter "name=garage"
|
||||
|
||||
## health: Check health status of containers
|
||||
health:
|
||||
@echo "=== Container Status ==="
|
||||
@docker ps --filter "name=garage" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
|
||||
## clean: Remove all containers, volumes, and images
|
||||
clean:
|
||||
$(DOCKER_COMPOSE_PROD) down -v --remove-orphans
|
||||
$(DOCKER_COMPOSE_DEV) down -v --remove-orphans
|
||||
docker system prune -f
|
||||
|
||||
## clean-volumes: Remove all volumes (WARNING: deletes data)
|
||||
clean-volumes:
|
||||
$(DOCKER_COMPOSE_PROD) down -v
|
||||
$(DOCKER_COMPOSE_DEV) down -v
|
||||
rm -rf ./meta ./data
|
||||
|
||||
## rebuild: Clean rebuild of development environment
|
||||
rebuild: dev-down dev-build dev-up
|
||||
|
||||
## install: Initial setup - create necessary directories
|
||||
install:
|
||||
@echo "Creating necessary directories..."
|
||||
@mkdir -p meta data
|
||||
@echo "Setup complete. Edit garage.toml and config.yaml before starting."
|
||||
|
||||
## update: Pull latest changes and rebuild
|
||||
update: prod-pull prod-restart
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -0,0 +1,197 @@
|
||||
# Garage UI
|
||||
|
||||
A web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) object storage clusters.
|
||||
|
||||
[](https://github.com/Noooste/garage-ui/actions/workflows/build.yml)
|
||||
[](https://github.com/Noooste/garage-ui/actions/workflows/release.yml)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://artifacthub.io/packages/search?repo=garage-ui)
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src=".github/assets/dashboard.png" alt="Dashboard" /></td>
|
||||
<td><img src=".github/assets/buckets.png" alt="Buckets" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src=".github/assets/cluster.png" alt="Cluster" /></td>
|
||||
<td><img src=".github/assets/access-control.png" alt="Access Control" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Features
|
||||
|
||||
- Bucket and object management
|
||||
- User access control
|
||||
- Cluster monitoring
|
||||
- Multiple authentication options (none/basic/OIDC)
|
||||
- Drag-and-drop file uploads
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker & Docker Compose
|
||||
- Garage S3 cluster (v2.1.0+) or use the included setup
|
||||
|
||||
### 1. Clone & Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Noooste/garage-ui.git
|
||||
cd garage-ui
|
||||
```
|
||||
|
||||
### 2. Start Garage
|
||||
|
||||
If you don't have Garage running:
|
||||
|
||||
```bash
|
||||
docker-compose up -d garage
|
||||
sleep 10
|
||||
|
||||
# Initialize cluster
|
||||
docker-compose exec garage garage layout assign -z dc1 -c 1G $(docker-compose exec garage garage node id -q)
|
||||
docker-compose exec garage garage layout apply --version 1
|
||||
|
||||
# Create admin key
|
||||
docker-compose exec garage garage key create admin-key
|
||||
```
|
||||
|
||||
Save the access key and secret key from the output.
|
||||
|
||||
### 3. Configure
|
||||
|
||||
```bash
|
||||
cp config.yaml.example config.yaml
|
||||
```
|
||||
|
||||
Edit `config.yaml` with your Garage endpoints and admin token (from `garage.toml`).
|
||||
|
||||
### 4. Start UI
|
||||
|
||||
```bash
|
||||
docker-compose up -d garage-ui
|
||||
```
|
||||
|
||||
Access at http://localhost:8080
|
||||
|
||||
## Configuration
|
||||
|
||||
Minimum required config:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
garage:
|
||||
endpoint: "http://garage:3900"
|
||||
admin_endpoint: "http://garage:3903"
|
||||
admin_token: "your-admin-token"
|
||||
region: "garage"
|
||||
```
|
||||
|
||||
Enable authentication (optional):
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
admin:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password: "your-password"
|
||||
```
|
||||
|
||||
See [config.yaml.example](config.yaml.example) for all options.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Override any config value with `GARAGE_UI_` prefix:
|
||||
|
||||
```bash
|
||||
GARAGE_UI_SERVER_PORT=8080
|
||||
GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900
|
||||
GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run -d -p 8080:8080 \
|
||||
-v $(pwd)/config.yaml:/app/config.yaml \
|
||||
noooste/garage-ui:latest
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
```bash
|
||||
helm repo add garage-ui https://helm.noste.dev/
|
||||
helm install garage-ui garage-ui/garage-ui \
|
||||
--set garage.endpoint=http://garage:3900 \
|
||||
--set garage.adminEndpoint=http://garage:3903 \
|
||||
--set garage.adminToken=your-token
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Backend (Go 1.25+):
|
||||
```bash
|
||||
cd backend
|
||||
go run main.go --config ../config.yaml
|
||||
```
|
||||
|
||||
Frontend (Node.js 25+):
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
API docs: http://localhost:8080/api/v1/
|
||||
|
||||
## Garage Configuration
|
||||
|
||||
Garage UI requires these settings in your `garage.toml`:
|
||||
|
||||
```toml
|
||||
# Admin API (required for Garage UI)
|
||||
[admin]
|
||||
api_bind_addr = "0.0.0.0:3903" # Default: 127.0.0.1:3903
|
||||
admin_token = "your-admin-token" # Generate with: openssl rand -base64 32
|
||||
|
||||
# S3 API
|
||||
[s3_api]
|
||||
s3_region = "garage" # Default: "garage"
|
||||
api_bind_addr = "[::]:3900" # Default: 127.0.0.1:3900
|
||||
```
|
||||
|
||||
**Important:** The `admin_token` and `s3_region` in `garage.toml` must match your Garage UI `config.yaml`.
|
||||
|
||||
For complete Garage configuration, see the [official documentation](https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Connection failed:**
|
||||
```bash
|
||||
curl http://localhost:3903/status -H "Authorization: Bearer your-token"
|
||||
```
|
||||
|
||||
**Enable debug logs:**
|
||||
```yaml
|
||||
logging:
|
||||
level: "debug"
|
||||
format: "text" # or "json"
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT - see [LICENSE](LICENSE)
|
||||
|
||||
## Links
|
||||
|
||||
- [Issues](https://github.com/Noooste/garage-ui/issues)
|
||||
- [Contributing](CONTRIBUTING.md)
|
||||
- [Garage Docs](https://garagehq.deuxfleurs.fr/documentation/)
|
||||
+27
-34
@@ -3,15 +3,15 @@ module Noooste/garage-ui
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/Noooste/azuretls-client v1.12.10
|
||||
github.com/Noooste/azuretls-client v1.12.11
|
||||
github.com/Noooste/swagger v1.2.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/gofiber/fiber/v3 v3.0.0-rc.3
|
||||
github.com/minio/minio-go/v7 v7.0.97
|
||||
github.com/gofiber/fiber/v3 v3.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/oauth2 v0.33.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -31,27 +31,20 @@ require (
|
||||
github.com/gaukas/godicttls v0.0.4 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/spec v0.22.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gofiber/schema v1.6.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.3 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/gofiber/schema v1.7.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.2 // indirect
|
||||
github.com/google/gopacket v1.1.19 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
@@ -61,24 +54,24 @@ require (
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/refraction-networking/utls v1.8.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
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/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/swaggo/swag v1.16.6 // indirect
|
||||
github.com/tinylib/msgp v1.6.3 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.68.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+67
-102
@@ -1,17 +1,15 @@
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Noooste/azuretls-client v1.12.9 h1:w6XVao95irzge8k+yF5ZovIKf7UBe0GVE3sTRKs/4wE=
|
||||
github.com/Noooste/azuretls-client v1.12.9/go.mod h1:GHqLaS+vjBk+D3fMA0d+gNgDS2ahtic+6c+7JyMfrdU=
|
||||
github.com/Noooste/azuretls-client v1.12.10 h1:wD+hSokcB1DCFSSLpj05bMGUCBV0wbPB/Z4uPFNEpoI=
|
||||
github.com/Noooste/azuretls-client v1.12.10/go.mod h1:nVPwYA6UgHrOinlpvj6t/zDTBV+UfT3t8vmab7WYxF0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Noooste/azuretls-client v1.12.11 h1:8IvtfPf+K6wOqiRROL/APGkxQCO/+jyjH0S39rnItfQ=
|
||||
github.com/Noooste/azuretls-client v1.12.11/go.mod h1:lvXW8wpaOwrwtDrSt8nv/Dd8NAbCMVNRoU4sFrAaxYs=
|
||||
github.com/Noooste/fhttp v1.0.15 h1:sYRWOKgr1x4L+wA6REMJCs4Z/lFOSJmuQHSIXMXCcPs=
|
||||
github.com/Noooste/fhttp v1.0.15/go.mod h1:YZtq+i2M11Y22UiOR6gjNSLMNLiPhURh6M44oFVQ1TE=
|
||||
github.com/Noooste/go-socks4 v0.0.2 h1:DwHCYiCEAdjfNrQOFIid7qgKCll7ubhGS1ji5O8FYng=
|
||||
github.com/Noooste/go-socks4 v0.0.2/go.mod h1:+oOgtOFRsU8FoK7NBOhHSjiH5pveY8LgYNF5XcqVgjE=
|
||||
github.com/Noooste/swagger v1.2.0 h1:zGHin8k2V9mXDB1gxXOdKe4V8zhw79ycsw+/L2hH/pk=
|
||||
github.com/Noooste/swagger v1.2.0/go.mod h1:5N+iUZlFA43k2Paf42EZ+SFndBG1niSA1FAnwiNP1PM=
|
||||
github.com/Noooste/uquic-go v1.0.1 h1:12ARejbnh0R5FLGoHhz4p+qT/8BKkRNTPsJlkX4/pg8=
|
||||
github.com/Noooste/uquic-go v1.0.1/go.mod h1:6AUIck22N0wQ5+CY/5pLmW3IzdITlf7ABtjbRsaZq2A=
|
||||
github.com/Noooste/uquic-go v1.0.3 h1:VP8npQmU4lkVLm9Ug5Q18SJ8ExFDfUZIzd13YjYaLHE=
|
||||
github.com/Noooste/uquic-go v1.0.3/go.mod h1:MxkrvgpNcbIOSQxqglC3e/798O/6zuL3mBhlFN+04w4=
|
||||
github.com/Noooste/utls v1.3.20 h1:QzBNGGJ184bNMLodOzvM9YWc4vZ36QodIjqFQOHoZ88=
|
||||
@@ -33,8 +31,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
@@ -49,54 +45,30 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
|
||||
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
|
||||
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-rc.3 h1:h0KXuRHbivSslIpoHD1R/XjUsjcGwt+2vK0avFiYonA=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-rc.3/go.mod h1:LNBPuS/rGoUFlOyy03fXsWAeWfdGoT1QytwjRVNSVWo=
|
||||
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
|
||||
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.2 h1:NvJTf7yMafTq16lUOJv70nr+HIOLNQcvGme/X+ftbW8=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.2/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.3 h1:gOL5jAEGUT2UbQkTkgMJctYt4rYewnTIt0Y7YaDATDc=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.3/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
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/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
|
||||
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
|
||||
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
|
||||
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
|
||||
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=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
@@ -105,24 +77,21 @@ github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+H
|
||||
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
|
||||
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.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@@ -134,14 +103,12 @@ 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.80 h1:2mdUHXEykRdY/BigLt3Iuu1otL0JTogT0Nmltg0wujk=
|
||||
github.com/minio/minio-go/v7 v7.0.80/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0=
|
||||
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
||||
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
@@ -153,16 +120,18 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
|
||||
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
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.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
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/v2 v2.4.0 h1:O5Z08MRmbo0lA9o2xnQ4TXx6teJbPqEurqcCOQ8Oi/4=
|
||||
github.com/shamaton/msgpack/v2 v2.4.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
|
||||
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
@@ -179,61 +148,57 @@ github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU
|
||||
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc=
|
||||
github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
|
||||
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
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=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
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.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
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=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -13,9 +13,10 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// AuthService handles authentication operations
|
||||
type AuthService struct {
|
||||
config *config.AuthConfig
|
||||
// Service handles authentication operations
|
||||
type Service struct {
|
||||
authConfig *config.AuthConfig
|
||||
serverConfig *config.ServerConfig
|
||||
oidcProvider *oidc.Provider
|
||||
oidcVerifier *oidc.IDTokenVerifier
|
||||
oauth2Config *oauth2.Config
|
||||
@@ -31,19 +32,20 @@ type UserInfo struct {
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) {
|
||||
jwtService, err := NewJWTService()
|
||||
func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*Service, error) {
|
||||
jwtService, err := NewJWTServiceWithKey(authCfg.JWTPrivKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize JWT service: %w", err)
|
||||
}
|
||||
|
||||
service := &AuthService{
|
||||
config: cfg,
|
||||
jwtService: jwtService,
|
||||
service := &Service{
|
||||
authConfig: authCfg,
|
||||
serverConfig: serverCfg,
|
||||
jwtService: jwtService,
|
||||
}
|
||||
|
||||
// Initialize OIDC if enabled
|
||||
if cfg.Mode == "oidc" && cfg.OIDC.Enabled {
|
||||
if authCfg.OIDC.Enabled {
|
||||
if err := service.initOIDC(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OIDC: %w", err)
|
||||
}
|
||||
@@ -53,11 +55,11 @@ func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) {
|
||||
}
|
||||
|
||||
// initOIDC initializes the OIDC provider and configuration
|
||||
func (a *AuthService) initOIDC() error {
|
||||
func (a *Service) initOIDC() error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create OIDC provider
|
||||
provider, err := oidc.NewProvider(ctx, a.config.OIDC.IssuerURL)
|
||||
provider, err := oidc.NewProvider(ctx, a.authConfig.OIDC.IssuerURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OIDC provider: %w", err)
|
||||
}
|
||||
@@ -66,34 +68,39 @@ func (a *AuthService) initOIDC() error {
|
||||
|
||||
// Create ID token verifier
|
||||
verifierConfig := &oidc.Config{
|
||||
ClientID: a.config.OIDC.ClientID,
|
||||
SkipIssuerCheck: a.config.OIDC.SkipIssuerCheck,
|
||||
SkipExpiryCheck: a.config.OIDC.SkipExpiryCheck,
|
||||
ClientID: a.authConfig.OIDC.ClientID,
|
||||
SkipIssuerCheck: a.authConfig.OIDC.SkipIssuerCheck,
|
||||
SkipExpiryCheck: a.authConfig.OIDC.SkipExpiryCheck,
|
||||
}
|
||||
a.oidcVerifier = provider.Verifier(verifierConfig)
|
||||
|
||||
// Construct redirect URL from server config
|
||||
// Use root_url if set, otherwise construct from protocol/domain
|
||||
redirectURL := a.serverConfig.RootURL + "/auth/oidc/callback"
|
||||
|
||||
// Create OAuth2 config
|
||||
a.oauth2Config = &oauth2.Config{
|
||||
ClientID: a.config.OIDC.ClientID,
|
||||
ClientSecret: a.config.OIDC.ClientSecret,
|
||||
ClientID: a.authConfig.OIDC.ClientID,
|
||||
ClientSecret: a.authConfig.OIDC.ClientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: a.config.OIDC.Scopes,
|
||||
Scopes: a.authConfig.OIDC.Scopes,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateBasicAuth validates basic authentication credentials
|
||||
func (a *AuthService) ValidateBasicAuth(username, password string) bool {
|
||||
func (a *Service) ValidateBasicAuth(username, password string) bool {
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
usernameMatch := subtle.ConstantTimeCompare(
|
||||
[]byte(username),
|
||||
[]byte(a.config.Basic.Username),
|
||||
[]byte(a.authConfig.Admin.Username),
|
||||
) == 1
|
||||
|
||||
passwordMatch := subtle.ConstantTimeCompare(
|
||||
[]byte(password),
|
||||
[]byte(a.config.Basic.Password),
|
||||
[]byte(a.authConfig.Admin.Password),
|
||||
) == 1
|
||||
|
||||
return usernameMatch && passwordMatch
|
||||
@@ -129,7 +136,7 @@ func ParseBasicAuth(authHeader string) (username, password string, ok bool) {
|
||||
}
|
||||
|
||||
// GetAuthorizationURL returns the OIDC authorization URL for login
|
||||
func (a *AuthService) GetAuthorizationURL(state string) (string, error) {
|
||||
func (a *Service) GetAuthorizationURL(state string) (string, error) {
|
||||
if a.oauth2Config == nil {
|
||||
return "", fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
@@ -138,7 +145,7 @@ func (a *AuthService) GetAuthorizationURL(state string) (string, error) {
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges an authorization code for tokens
|
||||
func (a *AuthService) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
|
||||
func (a *Service) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
|
||||
if a.oauth2Config == nil {
|
||||
return nil, fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
@@ -152,7 +159,7 @@ func (a *AuthService) ExchangeCode(ctx context.Context, code string) (*oauth2.To
|
||||
}
|
||||
|
||||
// VerifyIDToken verifies an OIDC ID token and extracts user info
|
||||
func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserInfo, error) {
|
||||
func (a *Service) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserInfo, error) {
|
||||
if a.oidcVerifier == nil {
|
||||
return nil, fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
@@ -171,21 +178,21 @@ func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*Us
|
||||
|
||||
// Extract user information using configured attributes
|
||||
userInfo := &UserInfo{
|
||||
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
|
||||
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
|
||||
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
|
||||
Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute),
|
||||
Email: extractClaim(claims, a.authConfig.OIDC.EmailAttribute),
|
||||
Name: extractClaim(claims, a.authConfig.OIDC.NameAttribute),
|
||||
}
|
||||
|
||||
// Extract roles if configured
|
||||
if a.config.OIDC.RoleAttributePath != "" {
|
||||
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
|
||||
if a.authConfig.OIDC.RoleAttributePath != "" {
|
||||
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
// GetUserInfo retrieves user information from the OIDC provider
|
||||
func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
|
||||
func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
|
||||
if a.oidcProvider == nil {
|
||||
return nil, fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
@@ -207,27 +214,27 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us
|
||||
|
||||
// Build user info
|
||||
userInfo := &UserInfo{
|
||||
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
|
||||
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
|
||||
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
|
||||
Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute),
|
||||
Email: extractClaim(claims, a.authConfig.OIDC.EmailAttribute),
|
||||
Name: extractClaim(claims, a.authConfig.OIDC.NameAttribute),
|
||||
}
|
||||
|
||||
// Extract roles if configured
|
||||
if a.config.OIDC.RoleAttributePath != "" {
|
||||
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
|
||||
if a.authConfig.OIDC.RoleAttributePath != "" {
|
||||
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user has admin role
|
||||
func (a *AuthService) IsAdmin(userInfo *UserInfo) bool {
|
||||
if a.config.OIDC.AdminRole == "" {
|
||||
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
|
||||
if a.authConfig.OIDC.AdminRole == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, role := range userInfo.Roles {
|
||||
if role == a.config.OIDC.AdminRole {
|
||||
if role == a.authConfig.OIDC.AdminRole {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -309,22 +316,22 @@ func extractStringArray(value interface{}) []string {
|
||||
}
|
||||
|
||||
// GenerateStateToken generates a secure CSRF state token
|
||||
func (a *AuthService) GenerateStateToken() (string, error) {
|
||||
func (a *Service) GenerateStateToken() (string, error) {
|
||||
return a.jwtService.GenerateStateToken()
|
||||
}
|
||||
|
||||
// ValidateAndConsumeState validates and consumes a CSRF state token
|
||||
func (a *AuthService) ValidateAndConsumeState(token string) bool {
|
||||
func (a *Service) ValidateAndConsumeState(token string) bool {
|
||||
return a.jwtService.ValidateAndConsumeState(token)
|
||||
}
|
||||
|
||||
// GenerateSessionToken generates a JWT session token for the user
|
||||
func (a *AuthService) GenerateSessionToken(userInfo *UserInfo) (string, error) {
|
||||
return a.jwtService.GenerateToken(userInfo, a.config.OIDC.SessionMaxAge)
|
||||
func (a *Service) GenerateSessionToken(userInfo *UserInfo) (string, error) {
|
||||
return a.jwtService.GenerateToken(userInfo, a.authConfig.OIDC.SessionMaxAge)
|
||||
}
|
||||
|
||||
// ValidateSessionToken validates a JWT session token and returns user info
|
||||
func (a *AuthService) ValidateSessionToken(tokenString string) (*UserInfo, error) {
|
||||
func (a *Service) ValidateSessionToken(tokenString string) (*UserInfo, error) {
|
||||
claims, err := a.jwtService.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type JWTService struct {
|
||||
privateKey ed25519.PrivateKey
|
||||
publicKey ed25519.PublicKey
|
||||
stateStore *StateStore
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type StateStore struct {
|
||||
mu sync.RWMutex
|
||||
states map[string]StateData
|
||||
}
|
||||
|
||||
type StateData struct {
|
||||
Created time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type SessionClaims struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Roles []string `json:"roles"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewJWTService() (*JWTService, error) {
|
||||
return NewJWTServiceWithKey("")
|
||||
}
|
||||
|
||||
func NewJWTServiceWithKey(privateKeyPEM string) (*JWTService, error) {
|
||||
var privateKey ed25519.PrivateKey
|
||||
var publicKey ed25519.PublicKey
|
||||
var err error
|
||||
|
||||
if privateKeyPEM != "" {
|
||||
// Parse the provided PEM-encoded private key
|
||||
privateKey, err = parseEd25519PrivateKeyFromPEM(privateKeyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Ed25519 private key: %w", err)
|
||||
}
|
||||
publicKey = privateKey.Public().(ed25519.PublicKey)
|
||||
} else {
|
||||
// Generate a new Ed25519 key pair if no key is provided
|
||||
publicKey, privateKey, err = ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate Ed25519 key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &JWTService{
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
stateStore: &StateStore{
|
||||
states: make(map[string]StateData),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseEd25519PrivateKeyFromPEM(privateKeyPEM string) (ed25519.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode PEM block")
|
||||
}
|
||||
|
||||
// Try to parse as PKCS#8 format (standard format from openssl genpkey -algorithm ED25519)
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err == nil {
|
||||
// Successfully parsed as PKCS#8, check if it's an Ed25519 key
|
||||
if ed25519Key, ok := key.(ed25519.PrivateKey); ok {
|
||||
return ed25519Key, nil
|
||||
}
|
||||
return nil, fmt.Errorf("PKCS#8 key is not an Ed25519 key")
|
||||
}
|
||||
|
||||
// Fallback: Check if it's raw Ed25519 private key bytes (64 bytes)
|
||||
if len(block.Bytes) == ed25519.PrivateKeySize {
|
||||
return block.Bytes, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid Ed25519 private key format: not PKCS#8 and not raw %d bytes (got %d bytes)",
|
||||
ed25519.PrivateKeySize, len(block.Bytes))
|
||||
}
|
||||
|
||||
func (j *JWTService) GenerateStateToken() (string, error) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate state token: %w", err)
|
||||
}
|
||||
|
||||
token := base64.URLEncoding.EncodeToString(tokenBytes)
|
||||
|
||||
j.stateStore.mu.Lock()
|
||||
defer j.stateStore.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
j.stateStore.states[token] = StateData{
|
||||
Created: now,
|
||||
ExpiresAt: now.Add(10 * time.Minute),
|
||||
}
|
||||
|
||||
go j.cleanupExpiredStates()
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (j *JWTService) ValidateAndConsumeState(token string) bool {
|
||||
j.stateStore.mu.Lock()
|
||||
defer j.stateStore.mu.Unlock()
|
||||
|
||||
state, exists := j.stateStore.states[token]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
if time.Now().After(state.ExpiresAt) {
|
||||
delete(j.stateStore.states, token)
|
||||
return false
|
||||
}
|
||||
|
||||
delete(j.stateStore.states, token)
|
||||
return true
|
||||
}
|
||||
|
||||
func (j *JWTService) cleanupExpiredStates() {
|
||||
j.stateStore.mu.Lock()
|
||||
defer j.stateStore.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for token, state := range j.stateStore.states {
|
||||
if now.After(state.ExpiresAt) {
|
||||
delete(j.stateStore.states, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (string, error) {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
|
||||
if j.privateKey == nil {
|
||||
return "", fmt.Errorf("private key not initialized")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second)
|
||||
|
||||
claims := SessionClaims{
|
||||
Username: userInfo.Username,
|
||||
Email: userInfo.Email,
|
||||
Name: userInfo.Name,
|
||||
Roles: userInfo.Roles,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
||||
tokenString, err := token.SignedString(j.privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func (j *JWTService) ValidateToken(tokenString string) (*SessionClaims, error) {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
|
||||
if j.publicKey == nil {
|
||||
return nil, fmt.Errorf("public key not initialized")
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &SessionClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return j.publicKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*SessionClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
func (j *JWTService) GetPublicKeyPEM() (string, error) {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
|
||||
if j.publicKey == nil {
|
||||
return "", fmt.Errorf("public key not initialized")
|
||||
}
|
||||
|
||||
pubKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: []byte(j.publicKey),
|
||||
})
|
||||
|
||||
return string(pubKeyPEM), nil
|
||||
}
|
||||
|
||||
// GetPublicKeyBase64 returns the base64url-encoded public key for JWKS
|
||||
func (j *JWTService) GetPublicKeyBase64() (string, error) {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
|
||||
if j.publicKey == nil {
|
||||
return "", fmt.Errorf("public key not initialized")
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(j.publicKey), nil
|
||||
}
|
||||
@@ -19,10 +19,17 @@ type Config struct {
|
||||
|
||||
// ServerConfig contains server-related configuration
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Environment string `mapstructure:"environment"`
|
||||
FrontendPath string `mapstructure:"frontend_path"` // Path to frontend dist directory
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Environment string `mapstructure:"environment"`
|
||||
FrontendPath string `mapstructure:"frontend_path"` // Path to frontend dist directory
|
||||
Domain string `mapstructure:"domain"` // Domain name (e.g., garage-ui.example.com)
|
||||
Protocol string `mapstructure:"protocol"` // Protocol for internal communication (http/https)
|
||||
RootURL string `mapstructure:"root_url"` // Full external URL for redirects (e.g., https://garage-ui.example.com)
|
||||
MaxBodySize int64 `mapstructure:"max_body_size"` // Maximum request body size in bytes (default: 300MB)
|
||||
MaxHeaderSize int `mapstructure:"max_header_size"` // Maximum request header size in bytes (default: 1MB)
|
||||
ReadBufferSize int `mapstructure:"read_buffer_size"` // Read buffer size in bytes (default: 4KB)
|
||||
WriteBufferSize int `mapstructure:"write_buffer_size"` // Write buffer size in bytes (default: 4KB)
|
||||
}
|
||||
|
||||
// GarageConfig contains Garage S3 connection settings
|
||||
@@ -37,13 +44,14 @@ type GarageConfig struct {
|
||||
|
||||
// AuthConfig contains authentication configuration
|
||||
type AuthConfig struct {
|
||||
Mode string `mapstructure:"mode"` // "none", "basic", or "oidc"
|
||||
Basic BasicAuthConfig `mapstructure:"basic"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
Admin AdminAuthConfig `mapstructure:"admin"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
|
||||
}
|
||||
|
||||
// BasicAuthConfig contains basic authentication settings
|
||||
type BasicAuthConfig struct {
|
||||
// AdminAuthConfig contains admin authentication settings
|
||||
type AdminAuthConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
}
|
||||
@@ -107,8 +115,7 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetEnvPrefix("GARAGE_UI")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
|
||||
// Bind environment variables to config keys
|
||||
// This ensures env vars override config file values
|
||||
// Env vars override config file values
|
||||
bindEnvVars()
|
||||
|
||||
// Read the config file (optional - will use defaults and env vars if not found)
|
||||
@@ -139,6 +146,13 @@ func bindEnvVars() {
|
||||
viper.BindEnv("server.port", "GARAGE_UI_SERVER_PORT")
|
||||
viper.BindEnv("server.environment", "GARAGE_UI_SERVER_ENVIRONMENT")
|
||||
viper.BindEnv("server.frontend_path", "GARAGE_UI_SERVER_FRONTEND_PATH")
|
||||
viper.BindEnv("server.domain", "GARAGE_UI_SERVER_DOMAIN")
|
||||
viper.BindEnv("server.protocol", "GARAGE_UI_SERVER_PROTOCOL")
|
||||
viper.BindEnv("server.root_url", "GARAGE_UI_SERVER_ROOT_URL")
|
||||
viper.BindEnv("server.max_body_size", "GARAGE_UI_SERVER_MAX_BODY_SIZE")
|
||||
viper.BindEnv("server.max_header_size", "GARAGE_UI_SERVER_MAX_HEADER_SIZE")
|
||||
viper.BindEnv("server.read_buffer_size", "GARAGE_UI_SERVER_READ_BUFFER_SIZE")
|
||||
viper.BindEnv("server.write_buffer_size", "GARAGE_UI_SERVER_WRITE_BUFFER_SIZE")
|
||||
|
||||
// Garage config
|
||||
viper.BindEnv("garage.endpoint", "GARAGE_UI_GARAGE_ENDPOINT")
|
||||
@@ -149,9 +163,10 @@ func bindEnvVars() {
|
||||
viper.BindEnv("garage.admin_token", "GARAGE_UI_GARAGE_ADMIN_TOKEN")
|
||||
|
||||
// Auth config
|
||||
viper.BindEnv("auth.mode", "GARAGE_UI_AUTH_MODE")
|
||||
viper.BindEnv("auth.basic.username", "GARAGE_UI_AUTH_BASIC_USERNAME")
|
||||
viper.BindEnv("auth.basic.password", "GARAGE_UI_AUTH_BASIC_PASSWORD")
|
||||
viper.BindEnv("auth.admin.enabled", "GARAGE_UI_AUTH_ADMIN_ENABLED")
|
||||
viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME")
|
||||
viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD")
|
||||
viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY")
|
||||
|
||||
// OIDC config
|
||||
viper.BindEnv("auth.oidc.enabled", "GARAGE_UI_AUTH_OIDC_ENABLED")
|
||||
@@ -208,28 +223,26 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("garage admin_token is required")
|
||||
}
|
||||
|
||||
// Validate auth mode
|
||||
if c.Auth.Mode != "none" && c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" {
|
||||
return fmt.Errorf("auth mode must be 'none', 'basic', or 'oidc', got: %s", c.Auth.Mode)
|
||||
}
|
||||
|
||||
// Validate basic auth if enabled
|
||||
if c.Auth.Mode == "basic" {
|
||||
if c.Auth.Basic.Username == "" || c.Auth.Basic.Password == "" {
|
||||
return fmt.Errorf("basic auth username and password are required when auth mode is 'basic'")
|
||||
// Validate admin auth if enabled
|
||||
if c.Auth.Admin.Enabled {
|
||||
if c.Auth.Admin.Username == "" || c.Auth.Admin.Password == "" {
|
||||
return fmt.Errorf("admin auth username and password are required when admin auth is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate OIDC config if enabled
|
||||
if c.Auth.Mode == "oidc" {
|
||||
if c.Auth.OIDC.Enabled {
|
||||
if c.Auth.OIDC.ClientID == "" {
|
||||
return fmt.Errorf("oidc client_id is required when auth mode is 'oidc'")
|
||||
return fmt.Errorf("oidc client_id is required when oidc is enabled")
|
||||
}
|
||||
if c.Auth.OIDC.IssuerURL == "" {
|
||||
return fmt.Errorf("oidc issuer_url is required when auth mode is 'oidc'")
|
||||
return fmt.Errorf("oidc issuer_url is required when oidc is enabled")
|
||||
}
|
||||
if c.Server.RootURL == "" {
|
||||
return fmt.Errorf("server.root_url is required when oidc is enabled")
|
||||
}
|
||||
if len(c.Auth.OIDC.Scopes) == 0 {
|
||||
return fmt.Errorf("oidc scopes are required when auth mode is 'oidc'")
|
||||
return fmt.Errorf("oidc scopes are required when oidc is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication-related requests
|
||||
type AuthHandler struct {
|
||||
cfg *config.Config
|
||||
authService *auth.Service
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
func NewAuthHandler(cfg *config.Config, authService *auth.Service) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
cfg: cfg,
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuthConfig returns the current authentication configuration
|
||||
//
|
||||
// @Summary Get authentication configuration
|
||||
// @Description Returns the current auth configuration (admin and/or OIDC)
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{admin=object,oidc=object} "Auth config"
|
||||
// @Router /auth/config [get]
|
||||
func (h *AuthHandler) GetAuthConfig(c fiber.Ctx) error {
|
||||
response := fiber.Map{
|
||||
"admin": fiber.Map{
|
||||
"enabled": h.cfg.Auth.Admin.Enabled,
|
||||
},
|
||||
"oidc": fiber.Map{
|
||||
"enabled": h.cfg.Auth.OIDC.Enabled,
|
||||
},
|
||||
}
|
||||
|
||||
// Add provider name if OIDC is enabled
|
||||
if h.cfg.Auth.OIDC.Enabled {
|
||||
provider := h.cfg.Auth.OIDC.ProviderName
|
||||
if provider == "" {
|
||||
provider = "OIDC Provider"
|
||||
}
|
||||
response["oidc"].(fiber.Map)["provider"] = provider
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// LoginBasicRequest represents the basic auth login request
|
||||
type LoginBasicRequest struct {
|
||||
Username string `json:"username" validate:"required"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
// LoginAdmin handles admin authentication login
|
||||
//
|
||||
// @Summary Admin auth login
|
||||
// @Description Authenticate with admin username and password, returns JWT token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param credentials body LoginBasicRequest true "Login credentials"
|
||||
// @Success 200 {object} object{success=bool,token=string,user=object} "Login successful"
|
||||
// @Failure 400 {object} models.APIResponse "Invalid request"
|
||||
// @Failure 401 {object} models.APIResponse "Invalid credentials"
|
||||
// @Router /auth/login [post]
|
||||
func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
|
||||
// Parse request body
|
||||
var req LoginBasicRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body"),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate credentials against admin config
|
||||
if req.Username != h.cfg.Auth.Admin.Username || req.Password != h.cfg.Auth.Admin.Password {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"),
|
||||
)
|
||||
}
|
||||
|
||||
// Create user info object
|
||||
userInfo := &auth.UserInfo{
|
||||
Username: req.Username,
|
||||
}
|
||||
|
||||
// Generate JWT session token
|
||||
sessionToken, err := h.authService.GenerateSessionToken(userInfo)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create session"),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"token": sessionToken,
|
||||
"user": fiber.Map{
|
||||
"username": userInfo.Username,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetMe returns the current authenticated user's information
|
||||
//
|
||||
// @Summary Get current user
|
||||
// @Description Returns information about the currently authenticated user
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 200 {object} object{success=bool,user=object} "User information"
|
||||
// @Failure 401 {object} models.APIResponse "Not authenticated"
|
||||
// @Router /auth/me [get]
|
||||
func (h *AuthHandler) GetMe(c fiber.Ctx) error {
|
||||
// Try to get user info from OIDC context
|
||||
userInfoInterface := c.Locals("userInfo")
|
||||
if userInfoInterface != nil {
|
||||
userInfo, ok := userInfoInterface.(*auth.UserInfo)
|
||||
if ok {
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"user": fiber.Map{
|
||||
"username": userInfo.Username,
|
||||
"email": userInfo.Email,
|
||||
"name": userInfo.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get username from basic auth context
|
||||
usernameInterface := c.Locals("username")
|
||||
if usernameInterface != nil {
|
||||
username, ok := usernameInterface.(string)
|
||||
if ok {
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"user": fiber.Map{
|
||||
"username": username,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Not authenticated"),
|
||||
)
|
||||
}
|
||||
@@ -54,19 +54,26 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
|
||||
continue
|
||||
}
|
||||
|
||||
bucketInfo := models.BucketInfo{
|
||||
Name: bucketName,
|
||||
CreationDate: adminBucket.Created,
|
||||
Region: "", // Garage doesn't have regions
|
||||
// Get detailed bucket info from Admin API to retrieve object count and size
|
||||
detailedInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
// If we can't get detailed info, return basic info without stats
|
||||
buckets = append(buckets, models.BucketInfo{
|
||||
Name: bucketName,
|
||||
CreationDate: adminBucket.Created,
|
||||
Region: "",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to get bucket statistics (object count and size)
|
||||
// This is done asynchronously to avoid blocking the response
|
||||
// If it fails, we still return the bucket info without stats
|
||||
stats, err := h.s3Service.GetBucketStatistics(ctx, bucketName)
|
||||
if err == nil && stats != nil {
|
||||
bucketInfo.ObjectCount = &stats.ObjectCount
|
||||
bucketInfo.Size = &stats.TotalSize
|
||||
bucketInfo := models.BucketInfo{
|
||||
Name: bucketName,
|
||||
CreationDate: adminBucket.Created,
|
||||
Region: "",
|
||||
ObjectCount: &detailedInfo.Objects,
|
||||
Size: &detailedInfo.Bytes,
|
||||
WebsiteAccess: detailedInfo.WebsiteAccess,
|
||||
WebsiteConfig: detailedInfo.WebsiteConfig,
|
||||
}
|
||||
|
||||
buckets = append(buckets, bucketInfo)
|
||||
@@ -111,25 +118,12 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if bucket already exists
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, req.Name)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if bucketInfo != nil {
|
||||
return c.Status(fiber.StatusConflict).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"),
|
||||
)
|
||||
}
|
||||
|
||||
// Create the bucket
|
||||
createBucketReq := models.CreateBucketAdminRequest{
|
||||
GlobalAlias: &req.Name,
|
||||
}
|
||||
if bucketInfo, err = h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
|
||||
|
||||
if _, err := h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()),
|
||||
)
|
||||
@@ -314,3 +308,77 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
}
|
||||
|
||||
// UpdateBucketWebsite updates the website access configuration for a bucket
|
||||
//
|
||||
// @Summary Update bucket website configuration
|
||||
// @Description Enables or disables static website hosting for a bucket
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket"
|
||||
// @Param request body models.UpdateBucketWebsiteRequest true "Website configuration"
|
||||
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Website configuration 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}/website [put]
|
||||
func (h *BucketHandler) UpdateBucketWebsite(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.UpdateBucketWebsiteRequest
|
||||
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.Enabled && req.IndexDocument == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "indexDocument is required when enabling website access"),
|
||||
)
|
||||
}
|
||||
|
||||
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"),
|
||||
)
|
||||
}
|
||||
|
||||
websiteAccess := &models.UpdateBucketWebsiteAccess{
|
||||
Enabled: req.Enabled,
|
||||
}
|
||||
if req.Enabled {
|
||||
websiteAccess.IndexDocument = &req.IndexDocument
|
||||
if req.ErrorDocument != "" {
|
||||
websiteAccess.ErrorDocument = &req.ErrorDocument
|
||||
}
|
||||
}
|
||||
|
||||
updateReq := models.UpdateBucketRequest{
|
||||
WebsiteAccess: websiteAccess,
|
||||
}
|
||||
|
||||
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 website: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -154,9 +155,14 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
|
||||
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
// Get bucket name from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
// Get object key from locals (set by route handler) or from params
|
||||
key, ok := c.Locals("objectKey").(string)
|
||||
if !ok || key == "" {
|
||||
key = c.Params("key")
|
||||
}
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
@@ -171,11 +177,10 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
|
||||
)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
// Set response headers
|
||||
c.Set("Content-Type", objectInfo.ContentType)
|
||||
c.Set("Content-Length", string(rune(objectInfo.Size)))
|
||||
c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10))
|
||||
c.Set("ETag", objectInfo.ETag)
|
||||
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
|
||||
|
||||
@@ -184,8 +189,11 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
c.Set("Content-Disposition", "attachment; filename=\""+key+"\"")
|
||||
}
|
||||
|
||||
// Stream the object body to the client
|
||||
return c.SendStream(body)
|
||||
// Stream the object body to the client without buffering the entire file
|
||||
return c.SendStreamWriter(func(w *bufio.Writer) {
|
||||
defer body.Close()
|
||||
io.Copy(w, body)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteObject deletes an object from a bucket
|
||||
@@ -205,9 +213,14 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
// Get bucket name from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
// Get object key from locals (set by route handler) or from params
|
||||
key, ok := c.Locals("objectKey").(string)
|
||||
if !ok || key == "" {
|
||||
key = c.Params("key")
|
||||
}
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
@@ -262,9 +275,14 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
|
||||
func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
// Get bucket name from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
// Get object key from locals (set by route handler) or from params
|
||||
key, ok := c.Locals("objectKey").(string)
|
||||
if !ok || key == "" {
|
||||
key = c.Params("key")
|
||||
}
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
@@ -301,9 +319,14 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
|
||||
func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
// Get bucket name from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
// Get object key from locals (set by route handler) or from params
|
||||
key, ok := c.Locals("objectKey").(string)
|
||||
if !ok || key == "" {
|
||||
key = c.Params("key")
|
||||
}
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
|
||||
@@ -251,6 +251,41 @@ func (h *UserHandler) GetUser(c fiber.Ctx) error {
|
||||
return c.JSON(models.SuccessResponse(userInfo))
|
||||
}
|
||||
|
||||
// GetUserSecretKey retrieves the secret key for a specific user/access key
|
||||
//
|
||||
// @Summary Get user secret key
|
||||
// @Description Retrieves the secret access key for a specific user/access key
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Param access_key path string true "Access key of the user to retrieve secret for"
|
||||
// @Success 200 {object} models.APIResponse{data=map[string]string} "Secret key retrieved successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get secret key"
|
||||
// @Router /api/v1/users/{access_key}/secret [get]
|
||||
func (h *UserHandler) GetUserSecretKey(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
accessKey := c.Params("access_key")
|
||||
|
||||
if accessKey == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get key information WITH secret key
|
||||
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, true)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get secret key: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Return only the secret key
|
||||
return c.JSON(models.SuccessResponse(map[string]string{
|
||||
"secretKey": *keyInfo.SecretAccessKey,
|
||||
}))
|
||||
}
|
||||
|
||||
// UpdateUserPermissions updates user permissions
|
||||
//
|
||||
// @Summary Update user permissions
|
||||
|
||||
@@ -8,126 +8,56 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthMiddleware returns a Fiber middleware for authentication
|
||||
// It handles different auth modes: none, basic, and OIDC
|
||||
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler {
|
||||
// AuthMiddleware supports admin and OIDC authentication
|
||||
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.Service) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
// If auth mode is "none", allow all requests
|
||||
if cfg.Mode == "none" {
|
||||
// If no auth is enabled, allow all requests
|
||||
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Handle basic authentication
|
||||
if cfg.Mode == "basic" {
|
||||
return handleBasicAuth(c, authService)
|
||||
// Get Authorization header
|
||||
authHeader := c.Get("Authorization")
|
||||
|
||||
// Try admin auth if enabled and header is present
|
||||
if cfg.Admin.Enabled && authHeader != "" {
|
||||
// Check if it's a Bearer token (JWT from admin login)
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
token := authHeader[7:]
|
||||
|
||||
// Validate JWT session token
|
||||
userInfo, err := authService.ValidateSessionToken(token)
|
||||
if err == nil {
|
||||
// Valid admin token
|
||||
c.Locals("userInfo", userInfo)
|
||||
c.Locals("username", userInfo.Username)
|
||||
if userInfo.Email != "" {
|
||||
c.Locals("email", userInfo.Email)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OIDC authentication
|
||||
if cfg.Mode == "oidc" {
|
||||
return handleOIDCAuth(c, authService, &cfg.OIDC)
|
||||
// Try OIDC auth if enabled
|
||||
if cfg.OIDC.Enabled {
|
||||
sessionCookie := c.Cookies(cfg.OIDC.CookieName)
|
||||
if sessionCookie != "" {
|
||||
// Validate JWT session token from cookie
|
||||
userInfo, err := authService.ValidateSessionToken(sessionCookie)
|
||||
if err == nil {
|
||||
// Valid OIDC token
|
||||
c.Locals("userInfo", userInfo)
|
||||
c.Locals("username", userInfo.Username)
|
||||
c.Locals("email", userInfo.Email)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown auth mode - deny access
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid authentication mode"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBasicAuth validates basic authentication credentials
|
||||
func handleBasicAuth(c fiber.Ctx, authService *auth.AuthService) error {
|
||||
// Get Authorization header
|
||||
authHeader := c.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Authorization header required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse basic auth credentials
|
||||
username, password, ok := auth.ParseBasicAuth(authHeader)
|
||||
if !ok {
|
||||
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid Authorization header format"),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate credentials
|
||||
if !authService.ValidateBasicAuth(username, password) {
|
||||
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"),
|
||||
)
|
||||
}
|
||||
|
||||
// Store username in context for later use
|
||||
c.Locals("username", username)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// handleOIDCAuth validates OIDC session/token
|
||||
func handleOIDCAuth(c fiber.Ctx, authService *auth.AuthService, oidcCfg *config.OIDCConfig) error {
|
||||
// Get session cookie
|
||||
sessionCookie := c.Cookies(oidcCfg.CookieName)
|
||||
if sessionCookie == "" {
|
||||
// No valid authentication found
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate JWT session token
|
||||
userInfo, err := authService.ValidateSessionToken(sessionCookie)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid or expired session"),
|
||||
)
|
||||
}
|
||||
|
||||
// Store user info in context for handlers to use
|
||||
c.Locals("userInfo", userInfo)
|
||||
c.Locals("username", userInfo.Username)
|
||||
c.Locals("email", userInfo.Email)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
func RequireAuth(cfg *config.AuthConfig) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
if cfg.Mode == "none" {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"),
|
||||
)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireAdmin(authService *auth.AuthService) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
userInfoInterface := c.Locals("userInfo")
|
||||
if userInfoInterface == nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
|
||||
)
|
||||
}
|
||||
|
||||
userInfo, ok := userInfoInterface.(*auth.UserInfo)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user has admin role
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Admin role required"),
|
||||
)
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,6 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ====================================
|
||||
// Access Key Models
|
||||
// ====================================
|
||||
|
||||
// GarageKeyInfo represents detailed information about a Garage access key
|
||||
type GarageKeyInfo struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
@@ -72,10 +68,6 @@ type ListKeysResponseItem struct {
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Models (Admin API)
|
||||
// ====================================
|
||||
|
||||
// GarageBucketInfo represents detailed information about a bucket from Admin API
|
||||
type GarageBucketInfo struct {
|
||||
ID string `json:"id"`
|
||||
@@ -153,10 +145,6 @@ type BucketLocalAlias struct {
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Alias Models
|
||||
// ====================================
|
||||
|
||||
// AddBucketAliasRequest represents the request to add a bucket alias
|
||||
type AddBucketAliasRequest struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
@@ -173,10 +161,6 @@ type RemoveBucketAliasRequest struct {
|
||||
AccessKeyID *string `json:"accessKeyId,omitempty"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Permission Models
|
||||
// ====================================
|
||||
|
||||
// BucketKeyPermRequest represents a request to change bucket-key permissions
|
||||
type BucketKeyPermRequest struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
@@ -184,10 +168,6 @@ type BucketKeyPermRequest struct {
|
||||
Permissions BucketKeyPermission `json:"permissions"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Cluster Models
|
||||
// ====================================
|
||||
|
||||
// ClusterHealth represents the health status of the cluster
|
||||
type ClusterHealth struct {
|
||||
Status string `json:"status"`
|
||||
@@ -211,10 +191,6 @@ type ClusterStatistics struct {
|
||||
Freeform string `json:"freeform"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Node Models
|
||||
// ====================================
|
||||
|
||||
// NodeInfo represents information about a cluster node
|
||||
type NodeInfo struct {
|
||||
ID string `json:"id"`
|
||||
@@ -59,3 +59,10 @@ type UpdateUserRequest struct {
|
||||
Status *string `json:"status,omitempty"` // "active" or "inactive"
|
||||
Expiration *string `json:"expiration,omitempty"` // ISO 8601 date string
|
||||
}
|
||||
|
||||
// UpdateBucketWebsiteRequest represents a request to update bucket website configuration
|
||||
type UpdateBucketWebsiteRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IndexDocument string `json:"indexDocument,omitempty"`
|
||||
ErrorDocument string `json:"errorDocument,omitempty"`
|
||||
}
|
||||
|
||||
@@ -40,11 +40,13 @@ type HealthResponse struct {
|
||||
|
||||
// BucketInfo represents information about a bucket
|
||||
type BucketInfo struct {
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creation_date"`
|
||||
ObjectCount *int64 `json:"object_count,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
ObjectCount *int64 `json:"objectCount,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
WebsiteAccess bool `json:"websiteAccess"`
|
||||
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
|
||||
}
|
||||
|
||||
// BucketListResponse represents a list of buckets
|
||||
@@ -55,12 +57,13 @@ type BucketListResponse struct {
|
||||
|
||||
// ObjectInfo represents information about an object
|
||||
type ObjectInfo struct {
|
||||
Key string `json:"key"`
|
||||
Size int64 `json:"size"`
|
||||
LastModified time.Time `json:"last_modified"`
|
||||
ETag string `json:"etag"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
StorageClass string `json:"storage_class,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Size int64 `json:"size"`
|
||||
LastModified time.Time `json:"last_modified"`
|
||||
ETag string `json:"etag"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
StorageClass string `json:"storage_class,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectListResponse represents a list of objects in a bucket
|
||||
@@ -120,7 +123,6 @@ type UserInfo struct {
|
||||
Name string `json:"name"`
|
||||
SecretKey *string `json:"secretKey,omitempty"`
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||
LastUsed *time.Time `json:"lastUsed,omitempty"`
|
||||
Status string `json:"status"` // "active" or "inactive"
|
||||
BucketPermissions []BucketPermission `json:"permissions"` // Array of bucket permissions
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
|
||||
@@ -5,7 +5,8 @@ import (
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/handlers"
|
||||
"Noooste/garage-ui/internal/middleware"
|
||||
"fmt"
|
||||
"Noooste/garage-ui/pkg/logger"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -21,7 +22,7 @@ import (
|
||||
func SetupRoutes(
|
||||
app *fiber.App,
|
||||
cfg *config.Config,
|
||||
authService *auth.AuthService,
|
||||
authService *auth.Service,
|
||||
healthHandler *handlers.HealthHandler,
|
||||
bucketHandler *handlers.BucketHandler,
|
||||
objectHandler *handlers.ObjectHandler,
|
||||
@@ -39,6 +40,12 @@ func SetupRoutes(
|
||||
// Swagger documentation endpoint (no auth required)
|
||||
app.Get("/docs/*", swagger.HandlerDefault)
|
||||
|
||||
// Create auth handler
|
||||
authHandler := handlers.NewAuthHandler(cfg, authService)
|
||||
|
||||
// Auth configuration endpoint (always accessible, no auth required)
|
||||
app.Get("/auth/config", authHandler.GetAuthConfig)
|
||||
|
||||
// API v1 group
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
@@ -53,6 +60,7 @@ func SetupRoutes(
|
||||
buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info
|
||||
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
|
||||
}
|
||||
|
||||
// Object routes
|
||||
@@ -62,18 +70,81 @@ func SetupRoutes(
|
||||
objects.Post("/", objectHandler.UploadObject) // Upload object (multipart)
|
||||
objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects
|
||||
objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects
|
||||
objects.Get("/:key", objectHandler.GetObject) // Download object
|
||||
objects.Delete("/:key", objectHandler.DeleteObject) // Delete object
|
||||
objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata
|
||||
objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL
|
||||
}
|
||||
|
||||
// Object-specific routes with wildcard key parameter (supports paths with slashes)
|
||||
// These need to be registered on the main app with auth middleware applied
|
||||
objectWildcardHandler := func(c fiber.Ctx) error {
|
||||
// Get the full path from wildcard parameter
|
||||
// Note: Fiber v3 does NOT automatically decode params, we need to do it manually
|
||||
path := c.Params("*")
|
||||
|
||||
// Decode the full path using QueryUnescape (handles %20, %2F, etc.)
|
||||
decodedPath, err := url.QueryUnescape(path)
|
||||
if err != nil {
|
||||
// If decoding fails, use the original path
|
||||
decodedPath = path
|
||||
}
|
||||
|
||||
// Check if it's a metadata request
|
||||
if strings.HasSuffix(decodedPath, "/metadata") {
|
||||
// Remove /metadata suffix to get the actual key
|
||||
key := strings.TrimSuffix(decodedPath, "/metadata")
|
||||
c.Locals("objectKey", key)
|
||||
return objectHandler.GetObjectMetadata(c)
|
||||
}
|
||||
// Check if it's a presign request
|
||||
if strings.HasSuffix(decodedPath, "/presign") {
|
||||
// Remove /presign suffix to get the actual key
|
||||
key := strings.TrimSuffix(decodedPath, "/presign")
|
||||
c.Locals("objectKey", key)
|
||||
return objectHandler.GetPresignedURL(c)
|
||||
}
|
||||
// Otherwise, it's a regular object download
|
||||
c.Locals("objectKey", decodedPath)
|
||||
return objectHandler.GetObject(c)
|
||||
}
|
||||
|
||||
objectDeleteHandler := func(c fiber.Ctx) error {
|
||||
path := c.Params("*")
|
||||
|
||||
// Decode the full path using QueryUnescape
|
||||
key, err := url.QueryUnescape(path)
|
||||
if err != nil {
|
||||
// If decoding fails, use the original path
|
||||
key = path
|
||||
}
|
||||
|
||||
c.Locals("objectKey", key)
|
||||
return objectHandler.DeleteObject(c)
|
||||
}
|
||||
|
||||
objectHeadHandler := func(c fiber.Ctx) error {
|
||||
path := c.Params("*")
|
||||
|
||||
// Decode the full path using QueryUnescape
|
||||
key, err := url.QueryUnescape(path)
|
||||
if err != nil {
|
||||
// If decoding fails, use the original path
|
||||
key = path
|
||||
}
|
||||
|
||||
c.Locals("objectKey", key)
|
||||
return objectHandler.GetObjectMetadata(c)
|
||||
}
|
||||
|
||||
// Register with auth middleware
|
||||
app.Get("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectWildcardHandler)
|
||||
app.Delete("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectDeleteHandler)
|
||||
app.Head("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectHeadHandler)
|
||||
|
||||
// User/Key management routes
|
||||
users := api.Group("/users")
|
||||
{
|
||||
users.Get("/", userHandler.ListUsers) // List all users/keys
|
||||
users.Post("/", userHandler.CreateUser) // Create new user/key
|
||||
users.Get("/:access_key", userHandler.GetUser) // Get user info
|
||||
users.Get("/:access_key/secret", userHandler.GetUserSecretKey) // Get user secret key
|
||||
users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key
|
||||
users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions
|
||||
}
|
||||
@@ -96,12 +167,22 @@ func SetupRoutes(
|
||||
monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
|
||||
}
|
||||
|
||||
// Admin auth login endpoint (only if admin is enabled)
|
||||
if cfg.Auth.Admin.Enabled {
|
||||
app.Post("/auth/login", authHandler.LoginAdmin)
|
||||
}
|
||||
|
||||
// Auth "me" endpoint (if any auth is enabled)
|
||||
if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled {
|
||||
app.Get("/auth/me", middleware.AuthMiddleware(&cfg.Auth, authService), authHandler.GetMe)
|
||||
}
|
||||
|
||||
// OIDC authentication routes (only if OIDC is enabled)
|
||||
if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled {
|
||||
authRoutes := app.Group("/auth")
|
||||
if cfg.Auth.OIDC.Enabled {
|
||||
oidcRoutes := app.Group("/auth/oidc")
|
||||
{
|
||||
// Login endpoint - redirects to OIDC provider
|
||||
authRoutes.Get("/login", func(c fiber.Ctx) error {
|
||||
oidcRoutes.Get("/login", func(c fiber.Ctx) error {
|
||||
state, err := authService.GenerateStateToken()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
@@ -119,7 +200,7 @@ func SetupRoutes(
|
||||
})
|
||||
|
||||
// Callback endpoint - handles OIDC redirect after login
|
||||
authRoutes.Get("/callback", func(c fiber.Ctx) error {
|
||||
oidcRoutes.Get("/callback", func(c fiber.Ctx) error {
|
||||
// Get and validate state token
|
||||
state := c.Query("state")
|
||||
if !authService.ValidateAndConsumeState(state) {
|
||||
@@ -161,6 +242,30 @@ func SetupRoutes(
|
||||
})
|
||||
}
|
||||
|
||||
// Enforce admin role if configured. Roles are often absent from the
|
||||
// ID token (e.g. Keycloak only emits resource_access in access tokens
|
||||
// unless the client scope is explicitly configured), so fall back to
|
||||
// the userinfo endpoint before denying access.
|
||||
if cfg.Auth.OIDC.AdminRole != "" {
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
if uiFromUserinfo, uiErr := authService.GetUserInfo(ctx, token); uiErr == nil {
|
||||
if len(uiFromUserinfo.Roles) > 0 {
|
||||
userInfo.Roles = uiFromUserinfo.Roles
|
||||
}
|
||||
}
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
logger.Warn().
|
||||
Str("username", userInfo.Username).
|
||||
Str("required_role", cfg.Auth.OIDC.AdminRole).
|
||||
Strs("roles", userInfo.Roles).
|
||||
Msg("OIDC login denied: user does not have required admin role")
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "User does not have the required admin role",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT session token
|
||||
sessionToken, err := authService.GenerateSessionToken(userInfo)
|
||||
if err != nil {
|
||||
@@ -179,14 +284,12 @@ func SetupRoutes(
|
||||
SameSite: cfg.Auth.OIDC.CookieSameSite,
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"user": userInfo,
|
||||
})
|
||||
// Redirect to frontend with success indicator
|
||||
return c.Redirect().To("/login?login=success")
|
||||
})
|
||||
|
||||
// Logout endpoint
|
||||
authRoutes.Post("/logout", func(c fiber.Ctx) error {
|
||||
oidcRoutes.Post("/logout", func(c fiber.Ctx) error {
|
||||
// Clear session cookie
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: cfg.Auth.OIDC.CookieName,
|
||||
@@ -199,36 +302,22 @@ func SetupRoutes(
|
||||
"message": "Logged out successfully",
|
||||
})
|
||||
})
|
||||
|
||||
// User info endpoint
|
||||
authRoutes.Get("/me", middleware.AuthMiddleware(&cfg.Auth, authService), func(c fiber.Ctx) error {
|
||||
userInfo := c.Locals("userInfo")
|
||||
if userInfo == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "Not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"user": userInfo,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cfg.Server.FrontendPath = "./frontend/dist"
|
||||
|
||||
// Check if frontend path exists
|
||||
if _, err := os.Stat(cfg.Server.FrontendPath); err == nil {
|
||||
fmt.Println("Serving frontend from:", cfg.Server.FrontendPath)
|
||||
|
||||
// SPA fallback - serve index.html for all non-API routes
|
||||
app.Use(func(c fiber.Ctx) error {
|
||||
path := c.Path()
|
||||
|
||||
if strings.HasPrefix(path, "/api/") ||
|
||||
strings.HasPrefix(path, "/auth") ||
|
||||
strings.HasPrefix(path, "/health") ||
|
||||
strings.HasPrefix(path, "/docs") {
|
||||
fmt.Println("API or health check route, skipping SPA fallback:", path)
|
||||
logger.Debug().Str("path", path).Msg("API or health check route, skipping SPA fallback")
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -20,9 +21,12 @@ type GarageAdminService struct {
|
||||
}
|
||||
|
||||
// NewGarageAdminService creates a new Garage Admin API service
|
||||
func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
|
||||
func NewGarageAdminService(cfg *config.GarageConfig, logLevel string) *GarageAdminService {
|
||||
session := azuretls.NewSession()
|
||||
session.Log()
|
||||
|
||||
if logLevel == "debug" {
|
||||
session.Log()
|
||||
}
|
||||
|
||||
return &GarageAdminService{
|
||||
baseURL: cfg.AdminEndpoint,
|
||||
@@ -31,17 +35,30 @@ func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
|
||||
}
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP request to the Admin API
|
||||
// doRequest performs an HTTP request to the Admin API with retry logic for connection refused errors
|
||||
func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
|
||||
return s.httpClient.Do(&azuretls.Request{
|
||||
Method: method,
|
||||
Url: s.baseURL + path,
|
||||
Body: body,
|
||||
IgnoreBody: true, // decodeResponse will handle body reading
|
||||
OrderedHeaders: azuretls.OrderedHeaders{
|
||||
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
|
||||
},
|
||||
}, ctx)
|
||||
var resp *azuretls.Response
|
||||
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var reqErr error
|
||||
resp, reqErr = s.httpClient.Do(&azuretls.Request{
|
||||
Method: method,
|
||||
Url: s.baseURL + path,
|
||||
Body: body,
|
||||
IgnoreBody: true, // decodeResponse will handle body reading
|
||||
OrderedHeaders: azuretls.OrderedHeaders{
|
||||
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
|
||||
},
|
||||
}, ctx)
|
||||
return reqErr
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// decodeResponse decodes a JSON response into the target structure
|
||||
@@ -62,10 +79,6 @@ func decodeResponse(resp *azuretls.Response, target interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Access Key Operations
|
||||
// ====================================
|
||||
|
||||
// ListKeys returns all access keys in the cluster
|
||||
func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil)
|
||||
@@ -164,10 +177,6 @@ func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKey
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Operations (Admin API)
|
||||
// ====================================
|
||||
|
||||
// ListBuckets returns all buckets in the cluster
|
||||
func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListBuckets", nil)
|
||||
@@ -210,7 +219,7 @@ func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAli
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
if err = decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
@@ -265,10 +274,6 @@ func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Alias Operations
|
||||
// ====================================
|
||||
|
||||
// AddBucketAlias adds an alias to a bucket
|
||||
func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req)
|
||||
@@ -299,10 +304,6 @@ func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.R
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Permission Operations
|
||||
// ====================================
|
||||
|
||||
// AllowBucketKey grants permissions for a key on a bucket
|
||||
func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AllowBucketKey", req)
|
||||
@@ -333,10 +334,6 @@ func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.Bucke
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Cluster Operations
|
||||
// ====================================
|
||||
|
||||
// GetClusterHealth returns the health status of the cluster
|
||||
func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil)
|
||||
@@ -382,10 +379,6 @@ func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Node Operations
|
||||
// ====================================
|
||||
|
||||
// GetNodeInfo returns information about a specific node
|
||||
func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID)
|
||||
@@ -420,10 +413,6 @@ func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID strin
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Monitoring Operations
|
||||
// ====================================
|
||||
|
||||
// HealthCheck checks if the Admin API is reachable
|
||||
func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
|
||||
+155
-71
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
@@ -24,9 +26,20 @@ type S3Service struct {
|
||||
// NewS3Service creates a new S3 service instance using MinIO SDK
|
||||
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
|
||||
// Create MinIO client for Garage
|
||||
// trim http or https from endpoint
|
||||
if strings.HasPrefix(cfg.Endpoint, "http://") {
|
||||
cfg.Endpoint = strings.TrimPrefix(cfg.Endpoint, "http://")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(cfg.Endpoint, "https://") {
|
||||
cfg.Endpoint = strings.TrimPrefix(cfg.Endpoint, "https://")
|
||||
cfg.UseSSL = true
|
||||
}
|
||||
|
||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
//Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
Region: cfg.Region,
|
||||
})
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to create MinIO client: %w", err))
|
||||
@@ -39,8 +52,6 @@ func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S
|
||||
}
|
||||
}
|
||||
|
||||
// getBucketCredentials retrieves credentials for a specific bucket
|
||||
// It checks the cache first, then queries the Garage Admin API
|
||||
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (*credentials.Credentials, error) {
|
||||
cacheKey := fmt.Sprintf("key:%s", bucketName)
|
||||
cacheData := utils.GlobalCache.Get(cacheKey)
|
||||
@@ -99,6 +110,7 @@ func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*min
|
||||
client, err := minio.New(s.config.Endpoint, &minio.Options{
|
||||
Creds: creds,
|
||||
Secure: s.config.UseSSL,
|
||||
Region: s.config.Region,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create MinIO client for bucket %s: %w", bucketName, err)
|
||||
@@ -109,8 +121,15 @@ func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*min
|
||||
|
||||
// ListBuckets retrieves all buckets from Garage
|
||||
func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) {
|
||||
// Call MinIO ListBuckets API
|
||||
bucketInfos, err := s.client.ListBuckets(ctx)
|
||||
var bucketInfos []minio.BucketInfo
|
||||
|
||||
// Call MinIO ListBuckets API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var listErr error
|
||||
bucketInfos, listErr = s.client.ListBuckets(ctx)
|
||||
return listErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list buckets: %w", err)
|
||||
}
|
||||
@@ -137,9 +156,12 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Call MinIO MakeBucket API
|
||||
err = client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
|
||||
Region: s.config.Region,
|
||||
// Call MinIO MakeBucket API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
return client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
|
||||
Region: s.config.Region,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
|
||||
@@ -155,8 +177,11 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Call MinIO RemoveBucket API
|
||||
err = client.RemoveBucket(ctx, bucketName)
|
||||
// Call MinIO RemoveBucket API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
return client.RemoveBucket(ctx, bucketName)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
|
||||
}
|
||||
@@ -177,64 +202,72 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
||||
maxKeys = 1000
|
||||
}
|
||||
|
||||
// Use ListObjectsV2 for proper pagination support
|
||||
opts := minio.ListObjectsOptions{
|
||||
Prefix: prefix,
|
||||
Recursive: false,
|
||||
MaxKeys: maxKeys,
|
||||
StartAfter: continuationToken,
|
||||
UseV1: false,
|
||||
// Create Core client for low-level API access
|
||||
core := &minio.Core{Client: client}
|
||||
|
||||
// Use Core.ListObjectsV2 for proper pagination with continuation tokens
|
||||
result, err := core.ListObjectsV2(
|
||||
bucketName,
|
||||
prefix, // objectPrefix
|
||||
"", // startAfter (empty when using continuationToken)
|
||||
continuationToken, // continuationToken (proper S3 token)
|
||||
"/", // delimiter (for folder listing)
|
||||
maxKeys, // maxkeys
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
objects := make([]models.ObjectInfo, 0)
|
||||
prefixesMap := make(map[string]bool)
|
||||
// Process objects from result.Contents
|
||||
// Note: ListObjectsV2 doesn't return ContentType, so we need to fetch it separately
|
||||
objects := make([]models.ObjectInfo, len(result.Contents))
|
||||
|
||||
var lastKey string
|
||||
isTruncated := false
|
||||
itemCount := 0
|
||||
// Use goroutines to fetch ContentType concurrently for better performance
|
||||
type statResult struct {
|
||||
index int
|
||||
contentType string
|
||||
err error
|
||||
}
|
||||
|
||||
// List objects using the channel-based API
|
||||
for object := range client.ListObjects(ctx, bucketName, opts) {
|
||||
if object.Err != nil {
|
||||
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, object.Err)
|
||||
}
|
||||
statChan := make(chan statResult, len(result.Contents))
|
||||
|
||||
// Check if this is a prefix (directory)
|
||||
if len(object.Key) > 0 && object.Key[len(object.Key)-1:] == "/" && object.Size == 0 {
|
||||
prefixesMap[object.Key] = true
|
||||
continue
|
||||
}
|
||||
for i, obj := range result.Contents {
|
||||
go func(idx int, objKey string) {
|
||||
// Fetch object metadata to get ContentType
|
||||
stat, err := client.StatObject(ctx, bucketName, objKey, minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
// If StatObject fails, we still include the object but without ContentType
|
||||
statChan <- statResult{index: idx, contentType: "", err: err}
|
||||
return
|
||||
}
|
||||
statChan <- statResult{index: idx, contentType: stat.ContentType, err: nil}
|
||||
}(i, obj.Key)
|
||||
|
||||
// Track the last key for pagination
|
||||
lastKey = object.Key
|
||||
|
||||
// Add to objects list
|
||||
objects = append(objects, models.ObjectInfo{
|
||||
Key: object.Key,
|
||||
Size: object.Size,
|
||||
LastModified: object.LastModified,
|
||||
ETag: object.ETag,
|
||||
ContentType: object.ContentType,
|
||||
StorageClass: object.StorageClass,
|
||||
})
|
||||
|
||||
itemCount++
|
||||
if itemCount >= maxKeys {
|
||||
isTruncated = true
|
||||
break
|
||||
// Initialize the object with basic info from ListObjectsV2
|
||||
objects[i] = models.ObjectInfo{
|
||||
Key: obj.Key,
|
||||
Size: obj.Size,
|
||||
LastModified: obj.LastModified,
|
||||
ETag: obj.ETag,
|
||||
StorageClass: obj.StorageClass,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert prefixes map to slice
|
||||
prefixList := make([]string, 0, len(prefixesMap))
|
||||
for p := range prefixesMap {
|
||||
prefixList = append(prefixList, p)
|
||||
// Collect results from goroutines
|
||||
for range result.Contents {
|
||||
res := <-statChan
|
||||
if res.err == nil {
|
||||
objects[res.index].ContentType = res.contentType
|
||||
}
|
||||
// If there was an error, ContentType remains empty, which is acceptable
|
||||
}
|
||||
close(statChan)
|
||||
|
||||
// Prepare next continuation token
|
||||
var nextToken string
|
||||
if isTruncated && lastKey != "" {
|
||||
nextToken = lastKey
|
||||
// Process folders from result.CommonPrefixes
|
||||
prefixList := make([]string, 0, len(result.CommonPrefixes))
|
||||
for _, p := range result.CommonPrefixes {
|
||||
prefixList = append(prefixList, p.Prefix)
|
||||
}
|
||||
|
||||
return &models.ObjectListResponse{
|
||||
@@ -242,8 +275,8 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
||||
Objects: objects,
|
||||
Prefixes: prefixList,
|
||||
Count: len(objects),
|
||||
IsTruncated: isTruncated,
|
||||
NextContinuationToken: nextToken,
|
||||
IsTruncated: result.IsTruncated,
|
||||
NextContinuationToken: result.NextContinuationToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -260,8 +293,15 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
|
||||
ContentType: contentType,
|
||||
}
|
||||
|
||||
// Call MinIO PutObject API
|
||||
info, err := client.PutObject(ctx, bucketName, key, body, -1, opts)
|
||||
var info minio.UploadInfo
|
||||
|
||||
// Call MinIO PutObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var uploadErr error
|
||||
info, uploadErr = client.PutObject(ctx, bucketName, key, body, -1, opts)
|
||||
return uploadErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
@@ -277,8 +317,21 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
|
||||
|
||||
// GetObject retrieves an object from a bucket
|
||||
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
// Call MinIO GetObject API
|
||||
object, err := s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
var object *minio.Object
|
||||
|
||||
// Call MinIO GetObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var getErr error
|
||||
object, getErr = client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
|
||||
return getErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
@@ -304,8 +357,17 @@ 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 {
|
||||
// Call MinIO RemoveObject API
|
||||
err := s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Call MinIO RemoveObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
return client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
@@ -321,7 +383,15 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
|
||||
return false, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
_, err = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
|
||||
var statErr error
|
||||
|
||||
// Call MinIO StatObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
_, statErr = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
|
||||
return statErr
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Check if error is "object not found"
|
||||
errResponse := minio.ToErrorResponse(err)
|
||||
@@ -341,7 +411,15 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
stat, err := client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
|
||||
var stat minio.ObjectInfo
|
||||
|
||||
// Call MinIO StatObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var statErr error
|
||||
stat, statErr = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
|
||||
return statErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
@@ -352,6 +430,8 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
|
||||
LastModified: stat.LastModified,
|
||||
ETag: stat.ETag,
|
||||
ContentType: stat.ContentType,
|
||||
StorageClass: stat.StorageClass,
|
||||
Metadata: stat.UserMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -402,8 +482,15 @@ func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string,
|
||||
return "", fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Generate presigned GET URL
|
||||
presignedURL, err := client.PresignedGetObject(ctx, bucketName, key, expiresIn, nil)
|
||||
var presignedURL *url.URL
|
||||
|
||||
// Generate presigned GET URL with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var presignErr error
|
||||
presignedURL, presignErr = client.PresignedGetObject(ctx, bucketName, key, expiresIn, nil)
|
||||
return presignErr
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err)
|
||||
}
|
||||
@@ -421,9 +508,6 @@ type UploadResult struct {
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// UploadMultipleObjects uploads multiple objects to a bucket
|
||||
// It handles uploads in batches to respect any S3/Garage limits
|
||||
// Returns results for each file, including both successes and failures
|
||||
func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
|
||||
+80
-32
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -13,9 +12,9 @@ import (
|
||||
"Noooste/garage-ui/internal/handlers"
|
||||
"Noooste/garage-ui/internal/routes"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
"Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/logger"
|
||||
"github.com/gofiber/fiber/v3/middleware/recover"
|
||||
)
|
||||
|
||||
@@ -25,9 +24,6 @@ import (
|
||||
// @description This API provides endpoints for managing buckets, objects, users, and cluster operations.
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
|
||||
// @contact.name API Support
|
||||
// @contact.email support@garage-ui.io
|
||||
|
||||
// @license.name MIT
|
||||
// @license.url https://opensource.org/licenses/MIT
|
||||
|
||||
@@ -58,33 +54,55 @@ import (
|
||||
// @name Authorization
|
||||
// @description Type "Bearer" followed by a space and JWT token.
|
||||
|
||||
const version = "0.1.0"
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
// Parse command-line flags
|
||||
configPath := flag.String("config", "config.yaml", "Path to configuration file")
|
||||
flag.Parse()
|
||||
|
||||
// Load configuration
|
||||
log.Printf("Loading configuration from: %s", *configPath)
|
||||
// Load configuration first (before initializing logger)
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load configuration: %v", err)
|
||||
// If config fails to load, use default logger to report the error
|
||||
logger.Get().Fatal().Err(err).Str("config_path", *configPath).Msg("Failed to load configuration")
|
||||
}
|
||||
|
||||
log.Printf("Starting Garage UI Backend v%s in %s mode", version, cfg.Server.Environment)
|
||||
// Initialize logger with configuration from config file
|
||||
logger.Init(logger.Config{
|
||||
Level: cfg.Logging.Level,
|
||||
Format: cfg.Logging.Format,
|
||||
})
|
||||
|
||||
// Now log with the properly configured logger
|
||||
logger.Info().
|
||||
Str("config_path", *configPath).
|
||||
Str("version", version).
|
||||
Str("environment", cfg.Server.Environment).
|
||||
Msg("Starting Garage UI Backend")
|
||||
|
||||
// Initialize services
|
||||
log.Println("Initializing Garage Admin service...")
|
||||
adminService := services.NewGarageAdminService(&cfg.Garage)
|
||||
logger.Info().Msg("Initializing Garage Admin service")
|
||||
adminService := services.NewGarageAdminService(&cfg.Garage, cfg.Logging.Level)
|
||||
|
||||
log.Println("Initializing S3 service...")
|
||||
logger.Info().Msg("Initializing S3 service")
|
||||
s3Service := services.NewS3Service(&cfg.Garage, adminService)
|
||||
|
||||
log.Printf("Initializing authentication service (mode: %s)...", cfg.Auth.Mode)
|
||||
authService, err := auth.NewAuthService(&cfg.Auth)
|
||||
// Determine enabled auth methods for logging
|
||||
authMethods := []string{}
|
||||
if cfg.Auth.Admin.Enabled {
|
||||
authMethods = append(authMethods, "admin")
|
||||
}
|
||||
if cfg.Auth.OIDC.Enabled {
|
||||
authMethods = append(authMethods, "oidc")
|
||||
}
|
||||
if len(authMethods) == 0 {
|
||||
authMethods = append(authMethods, "none")
|
||||
}
|
||||
logger.Info().Strs("enabled_methods", authMethods).Msg("Initializing authentication service")
|
||||
authService, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize auth service: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to initialize auth service")
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
@@ -95,22 +113,45 @@ func main() {
|
||||
clusterHandler := handlers.NewClusterHandler(adminService)
|
||||
monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service)
|
||||
|
||||
// Set default values for buffer sizes if not configured
|
||||
maxBodySize := cfg.Server.MaxBodySize
|
||||
if maxBodySize == 0 {
|
||||
maxBodySize = 300 * 1024 * 1024 // 300MB default
|
||||
}
|
||||
maxHeaderSize := cfg.Server.MaxHeaderSize
|
||||
if maxHeaderSize == 0 {
|
||||
maxHeaderSize = 1 * 1024 * 1024 // 1MB default
|
||||
}
|
||||
readBufferSize := cfg.Server.ReadBufferSize
|
||||
if readBufferSize == 0 {
|
||||
readBufferSize = 4096 // 4KB default
|
||||
}
|
||||
writeBufferSize := cfg.Server.WriteBufferSize
|
||||
if writeBufferSize == 0 {
|
||||
writeBufferSize = 4096 // 4KB default
|
||||
}
|
||||
|
||||
logger.Info().
|
||||
Int64("max_body_bytes", maxBodySize).
|
||||
Float64("max_body_mb", float64(maxBodySize)/(1024*1024)).
|
||||
Int("max_header_bytes", maxHeaderSize).
|
||||
Float64("max_header_kb", float64(maxHeaderSize)/1024).
|
||||
Msg("Server request limits configured")
|
||||
|
||||
// Create Fiber app with configuration
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Garage UI Backend v" + version,
|
||||
//DisableStartupMessage: false,
|
||||
//EnablePrintRoutes: cfg.IsDevelopment(),
|
||||
ErrorHandler: customErrorHandler,
|
||||
AppName: "Garage UI Backend v" + version,
|
||||
BodyLimit: int(maxBodySize),
|
||||
ReadBufferSize: readBufferSize,
|
||||
WriteBufferSize: writeBufferSize,
|
||||
ErrorHandler: customErrorHandler,
|
||||
})
|
||||
|
||||
// Apply global middleware
|
||||
app.Use(recover.New()) // Panic recovery
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "[${time}] ${status} - ${method} ${path} (${latency})\n",
|
||||
}))
|
||||
|
||||
// Setup routes
|
||||
log.Println("Setting up routes...")
|
||||
logger.Info().Msg("Setting up routes")
|
||||
routes.SetupRoutes(
|
||||
app,
|
||||
cfg,
|
||||
@@ -126,12 +167,14 @@ func main() {
|
||||
// Start server in a goroutine
|
||||
go func() {
|
||||
addr := cfg.GetAddress()
|
||||
log.Printf("Server listening on %s", addr)
|
||||
log.Printf("Health check available at: http://%s/health", addr)
|
||||
log.Printf("API documentation: http://%s/api/v1/", addr)
|
||||
logger.Info().
|
||||
Str("address", addr).
|
||||
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 {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to start server")
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -140,12 +183,12 @@ func main() {
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
logger.Info().Msg("Shutting down server")
|
||||
if err := app.Shutdown(); err != nil {
|
||||
log.Fatalf("Server shutdown failed: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Server shutdown failed")
|
||||
}
|
||||
|
||||
log.Println("Server stopped gracefully")
|
||||
logger.Info().Msg("Server stopped gracefully")
|
||||
}
|
||||
|
||||
// customErrorHandler handles errors globally
|
||||
@@ -159,7 +202,12 @@ func customErrorHandler(c fiber.Ctx, err error) error {
|
||||
}
|
||||
|
||||
// Log the error
|
||||
log.Printf("Error: %v", err)
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Int("status_code", code).
|
||||
Str("method", c.Method()).
|
||||
Str("path", c.Path()).
|
||||
Msg("Request error")
|
||||
|
||||
// Return JSON error response
|
||||
return c.Status(code).JSON(fiber.Map{
|
||||
|
||||
@@ -21,17 +21,16 @@ var (
|
||||
|
||||
// Config holds logger configuration
|
||||
type Config struct {
|
||||
Level string // debug, info, warn, error
|
||||
Pretty bool // Enable console pretty printing
|
||||
TimeFormat string // Time format (default: time.RFC3339)
|
||||
Level string // debug, info, warn, error
|
||||
Format string // json, text
|
||||
}
|
||||
|
||||
// Init initializes the global logger with the given configuration
|
||||
func Init(cfg Config) {
|
||||
var output io.Writer = os.Stdout
|
||||
|
||||
// Set up pretty console output if enabled
|
||||
if cfg.Pretty {
|
||||
// Set up console output for text format
|
||||
if cfg.Format == "text" {
|
||||
output = zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: time.RFC3339,
|
||||
@@ -70,7 +69,7 @@ func Get() *Logger {
|
||||
// Initialize with defaults if not initialized
|
||||
Init(Config{
|
||||
Level: "info",
|
||||
Pretty: true,
|
||||
Format: "text",
|
||||
})
|
||||
}
|
||||
return globalLogger
|
||||
|
||||
@@ -91,5 +91,4 @@ func (c *Cache) cleanupExpired() {
|
||||
}
|
||||
}
|
||||
|
||||
// Global cache instance
|
||||
var GlobalCache = NewCache()
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RetryConfig configures retry behavior
|
||||
type RetryConfig struct {
|
||||
MaxRetries int
|
||||
InitialBackoff time.Duration
|
||||
MaxBackoff time.Duration
|
||||
BackoffFactor float64
|
||||
}
|
||||
|
||||
// DefaultRetryConfig returns default retry configuration
|
||||
func DefaultRetryConfig() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: 3,
|
||||
InitialBackoff: 100 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Second,
|
||||
BackoffFactor: 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
// IsConnectionRefused checks if the error is a connection refused error
|
||||
func IsConnectionRefused(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for connection refused error
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
// Check if it's a connection refused error
|
||||
if opErr.Op == "dial" || opErr.Op == "read" {
|
||||
var syscallErr *syscall.Errno
|
||||
if errors.As(opErr.Err, &syscallErr) {
|
||||
return *syscallErr == syscall.ECONNREFUSED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for "connection refused" in error message as fallback
|
||||
if errors.Is(err, syscall.ECONNREFUSED) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// RetryWithBackoff executes a function with exponential backoff on connection refused errors
|
||||
func RetryWithBackoff(ctx context.Context, config RetryConfig, fn func() error) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||
// Execute the function
|
||||
err := fn()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// If it's not a connection refused error, don't retry
|
||||
if !IsConnectionRefused(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// If this was the last attempt, return the error
|
||||
if attempt == config.MaxRetries {
|
||||
return fmt.Errorf("max retries (%d) exceeded: %w", config.MaxRetries, err)
|
||||
}
|
||||
|
||||
// Calculate backoff duration with exponential increase
|
||||
backoff := time.Duration(float64(config.InitialBackoff) * pow(config.BackoffFactor, float64(attempt)))
|
||||
if backoff > config.MaxBackoff {
|
||||
backoff = config.MaxBackoff
|
||||
}
|
||||
|
||||
// Wait with context cancellation support
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("context cancelled during retry: %w", ctx.Err())
|
||||
case <-time.After(backoff):
|
||||
// Continue to next attempt
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// pow is a simple integer exponentiation helper
|
||||
func pow(base float64, exp float64) float64 {
|
||||
result := 1.0
|
||||
for i := 0; i < int(exp); i++ {
|
||||
result *= base
|
||||
}
|
||||
return result
|
||||
}
|
||||
+28
-10
@@ -5,29 +5,46 @@ server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
environment: "development" # development, production
|
||||
domain: "localhost" # Domain name for the application
|
||||
protocol: "http" # Protocol for internal communication (http/https)
|
||||
root_url: "http://localhost:8080" # Full external URL for OAuth2 redirects (adjust for production)
|
||||
|
||||
# Request size limits (in bytes)
|
||||
max_body_size: 314572800 # 300MB - Maximum request body size (increase for large file uploads)
|
||||
max_header_size: 1048576 # 1MB - Maximum request header size
|
||||
read_buffer_size: 4096 # 4KB - Read buffer size
|
||||
write_buffer_size: 4096 # 4KB - Write buffer size
|
||||
|
||||
# Garage S3 Configuration
|
||||
garage:
|
||||
endpoint: "http://localhost:3900" # Garage S3 API endpoint
|
||||
region: "eu-west-1" # S3 region (can be any value for Garage)
|
||||
region: "eu-west-1" # S3 region (ensure it matches Garage S3 configuration)
|
||||
|
||||
# Garage Admin API configuration
|
||||
admin_endpoint: "http://localhost:3903" # Garage Admin API endpoint
|
||||
admin_token: "changeme" # Admin API bearer token
|
||||
|
||||
# Authentication Configuration
|
||||
# You can enable one or both authentication methods
|
||||
auth:
|
||||
# Auth mode: "none", "basic", or "oidc"
|
||||
mode: "none"
|
||||
# JWT Configuration
|
||||
# Ed25519 private key in PEM format for JWT token signing
|
||||
# If not specified, a new key will be generated on each startup (tokens won't persist across restarts)
|
||||
# Generate with: openssl genpkey -algorithm ED25519 -out jwt-key.pem
|
||||
# The key is a 64-byte Ed25519 private key
|
||||
jwt_private_key: "" # Leave empty to auto-generate, or provide PEM-encoded Ed25519 private key
|
||||
|
||||
# Basic Authentication (only used when mode = "basic")
|
||||
basic:
|
||||
# Admin Authentication (username/password)
|
||||
admin:
|
||||
enabled: false # Set to true to enable admin login
|
||||
username: "admin"
|
||||
password: "changeme"
|
||||
|
||||
# OIDC Configuration (only used when mode = "oidc")
|
||||
# OIDC Configuration
|
||||
# NOTE: When OIDC is enabled, server.root_url is required for OAuth2 redirects
|
||||
# The redirect URL will be automatically constructed as: {root_url}/auth/oidc/callback
|
||||
oidc:
|
||||
enabled: true
|
||||
enabled: false # Set to true to enable OIDC login
|
||||
provider_name: "Keycloak"
|
||||
client_id: "garage-ui"
|
||||
client_secret: "your-client-secret"
|
||||
@@ -86,7 +103,8 @@ cors:
|
||||
allow_credentials: false
|
||||
max_age: 3600
|
||||
|
||||
# Logging
|
||||
# Logging Configuration
|
||||
# The application uses zerolog for structured logging
|
||||
logging:
|
||||
level: "info" # debug, info, warn, error
|
||||
format: "json" # json, text
|
||||
level: "info" # Options: debug, info, warn, error
|
||||
format: "text" or "json"
|
||||
|
||||
+9
-5
@@ -1,7 +1,7 @@
|
||||
---
|
||||
services:
|
||||
garage:
|
||||
image: dxflrs/garage:v2.0.0
|
||||
image: dxflrs/garage:v2.1.0
|
||||
container_name: garage
|
||||
volumes:
|
||||
- ./garage.toml:/etc/garage.toml
|
||||
@@ -26,7 +26,7 @@ services:
|
||||
- garage
|
||||
environment:
|
||||
# Garage S3 Configuration
|
||||
GARAGE_UI_GARAGE_ENDPOINT: "http://garage:3900"
|
||||
GARAGE_UI_GARAGE_ENDPOINT: "garage:3900"
|
||||
GARAGE_UI_GARAGE_ADMIN_ENDPOINT: "http://garage:3903"
|
||||
|
||||
# Server Configuration
|
||||
@@ -34,6 +34,10 @@ services:
|
||||
GARAGE_UI_SERVER_PORT: "8080"
|
||||
GARAGE_UI_SERVER_ENVIRONMENT: "production"
|
||||
|
||||
# Logging
|
||||
GARAGE_UI_LOGGING_LEVEL: "info"
|
||||
GARAGE_UI_LOGGING_FORMAT: "json"
|
||||
# JWT Configuration
|
||||
# IMPORTANT: Replace this with your own Ed25519 private key in production!
|
||||
# Generate with: openssl genpkey -algorithm ED25519
|
||||
GARAGE_UI_AUTH_JWT_PRIVATE_KEY: |
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS
|
||||
-----END PRIVATE KEY-----
|
||||
|
||||
@@ -143,7 +143,6 @@ list: async (): Promise<Bucket[]> => {
|
||||
- Activate/deactivate keys
|
||||
- Copy access key IDs
|
||||
- Permission configuration
|
||||
- Bucket policies (coming soon)
|
||||
|
||||
## Theme Customization
|
||||
|
||||
|
||||
Generated
+569
-739
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
+27
-1
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import {BrowserRouter, Route, Routes} from 'react-router-dom';
|
||||
import {QueryClientProvider} from '@tanstack/react-query';
|
||||
import {ThemeProvider, useTheme} from '@/components/theme-provider';
|
||||
@@ -6,8 +7,13 @@ import {Dashboard} from '@/pages/Dashboard';
|
||||
import {Buckets} from '@/pages/Buckets';
|
||||
import {Cluster} from '@/pages/Cluster';
|
||||
import {AccessControl} from '@/pages/AccessControl';
|
||||
import {Login} from '@/pages/Login';
|
||||
import {ObjectDetailsView} from '@/components/buckets/ObjectDetailsView';
|
||||
import {Toaster} from 'sonner';
|
||||
import {queryClient} from '@/lib/query-client';
|
||||
import {useAuthStore} from '@/store/auth-store';
|
||||
import {ProtectedRoute} from '@/components/auth/ProtectedRoute';
|
||||
import {LoadingSpinner} from '@/components/auth/LoadingSpinner';
|
||||
|
||||
function ThemedToaster() {
|
||||
const { theme } = useTheme();
|
||||
@@ -16,14 +22,34 @@ function ThemedToaster() {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { initialize, isLoading } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="system" storageKey="Noooste/garage-ui-theme">
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="buckets" element={<Buckets />} />
|
||||
<Route path="buckets/:bucketName/objects/*" element={<ObjectDetailsView />} />
|
||||
<Route path="cluster" element={<Cluster />} />
|
||||
<Route path="access" element={<AccessControl />} />
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import type { AuthConfig } from '@/types/auth';
|
||||
|
||||
interface BasicLoginFormProps {
|
||||
showOIDC?: boolean;
|
||||
config?: AuthConfig | null;
|
||||
}
|
||||
|
||||
export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { loginAdmin, loginOIDC } = useAuthStore();
|
||||
|
||||
const returnUrl = searchParams.get('returnUrl') || '/';
|
||||
const providerName = config?.oidc?.provider || 'OIDC Provider';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await loginAdmin(username, password);
|
||||
// Navigate to return URL on success
|
||||
navigate(decodeURIComponent(returnUrl));
|
||||
} catch (error) {
|
||||
// Error is already handled by the store and toast
|
||||
console.error('Login failed:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<img
|
||||
src="/garage.png"
|
||||
alt="Garage Logo"
|
||||
className="h-16 w-16 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">
|
||||
Welcome to Garage UI
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="text-sm font-medium">Username</label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Enter your username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">Password</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !username || !password}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{showOIDC && (
|
||||
<div className="mt-4">
|
||||
<div className="relative mb-4">
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="bg-card px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={loginOIDC}
|
||||
>
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
Sign in with {providerName}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LogIn } from 'lucide-react';
|
||||
|
||||
export function OIDCLoginView() {
|
||||
const { config, loginOIDC } = useAuthStore();
|
||||
|
||||
const providerName = config?.oidc.provider || 'OIDC Provider';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<img
|
||||
src="/garage.png"
|
||||
alt="Garage Logo"
|
||||
className="h-16 w-16 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">Sign in to Garage UI</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Authenticate using your {providerName} account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={loginOIDC}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<LogIn className="mr-2 h-5 w-5" />
|
||||
Continue with {providerName}
|
||||
</Button>
|
||||
<p className="mt-4 text-center text-xs text-muted-foreground">
|
||||
You will be redirected to {providerName} to complete the sign-in process
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading, config } = useAuthStore();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
// If no auth is enabled, always allow access
|
||||
if (config && !config.admin.enabled && !config.oidc.enabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// If not authenticated, redirect to login with return URL
|
||||
if (!isAuthenticated) {
|
||||
const returnUrl = encodeURIComponent(location.pathname + location.search);
|
||||
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
|
||||
import { formatBytes, formatDate } from '@/lib/utils';
|
||||
import { FolderIcon, Globe, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import type { Bucket } from '@/types';
|
||||
|
||||
interface BucketListViewProps {
|
||||
@@ -22,6 +23,7 @@ interface BucketListViewProps {
|
||||
onOpenSettings: (bucket: Bucket) => void;
|
||||
onCreateBucket: () => void;
|
||||
onDeleteBucket: (bucket: Bucket) => void;
|
||||
onWebsiteSettings: (bucket: Bucket) => void;
|
||||
}
|
||||
|
||||
export function BucketListView({
|
||||
@@ -33,6 +35,7 @@ export function BucketListView({
|
||||
onOpenSettings,
|
||||
onCreateBucket,
|
||||
onDeleteBucket,
|
||||
onWebsiteSettings,
|
||||
}: BucketListViewProps) {
|
||||
const filteredBuckets = buckets.filter((bucket) =>
|
||||
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
@@ -93,7 +96,15 @@ export function BucketListView({
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => onViewBucket(bucket.name)}
|
||||
>
|
||||
<TableCell className="font-medium truncate max-w-[200px]">{bucket.name}</TableCell>
|
||||
<TableCell className="font-medium max-w-[200px]">
|
||||
<span className="truncate">{bucket.name}</span>
|
||||
{bucket.websiteAccess && (
|
||||
<Badge variant="outline" className="text-xs ml-2">
|
||||
<Globe className="h-3 w-3 mr-1" />
|
||||
Website
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
|
||||
</TableCell>
|
||||
@@ -122,6 +133,13 @@ export function BucketListView({
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onWebsiteSettings(bucket);
|
||||
}}>
|
||||
<Globe className="h-4 w-4" />
|
||||
Website Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { accessApi } from '@/lib/api';
|
||||
import type { AccessKey, Bucket } from '@/types';
|
||||
import { useAccessKeys } from '@/hooks/useApi';
|
||||
import type { Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface BucketSettingsDialogProps {
|
||||
@@ -22,7 +22,7 @@ interface BucketSettingsDialogProps {
|
||||
}
|
||||
|
||||
export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermission }: BucketSettingsDialogProps) {
|
||||
const [availableKeys, setAvailableKeys] = useState<AccessKey[]>([]);
|
||||
const { data: availableKeys = [] } = useAccessKeys();
|
||||
const [selectedAccessKey, setSelectedAccessKey] = useState<string>('');
|
||||
const [permissionRead, setPermissionRead] = useState(false);
|
||||
const [permissionWrite, setPermissionWrite] = useState(false);
|
||||
@@ -30,20 +30,10 @@ export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermis
|
||||
|
||||
useEffect(() => {
|
||||
if (open && bucket) {
|
||||
loadAccessKeys();
|
||||
resetForm();
|
||||
}
|
||||
}, [open, bucket]);
|
||||
|
||||
const loadAccessKeys = async () => {
|
||||
try {
|
||||
const keys = await accessApi.listKeys();
|
||||
setAvailableKeys(keys);
|
||||
} catch (error) {
|
||||
console.error('Failed to load access keys:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedAccessKey('');
|
||||
setPermissionRead(false);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { Bucket } from '@/types';
|
||||
|
||||
interface BucketWebsiteDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
bucket: Bucket | null;
|
||||
onSave: (
|
||||
bucketName: string,
|
||||
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function BucketWebsiteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
bucket,
|
||||
onSave,
|
||||
}: BucketWebsiteDialogProps) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [indexDocument, setIndexDocument] = useState('index.html');
|
||||
const [errorDocument, setErrorDocument] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && bucket) {
|
||||
setEnabled(bucket.websiteAccess);
|
||||
setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html');
|
||||
setErrorDocument(bucket.websiteConfig?.errorDocument ?? '');
|
||||
}
|
||||
}, [open, bucket]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!bucket) return;
|
||||
setSaving(true);
|
||||
const success = await onSave(bucket.name, {
|
||||
enabled,
|
||||
indexDocument: enabled ? indexDocument : undefined,
|
||||
errorDocument: enabled && errorDocument ? errorDocument : undefined,
|
||||
});
|
||||
setSaving(false);
|
||||
if (success) onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Website Hosting — {bucket?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure this bucket to serve a static website.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Website access</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Allow public HTTP access to bucket objects
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={enabled ? 'default' : 'secondary'}>
|
||||
{enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Index document <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={indexDocument}
|
||||
onChange={(e) => setIndexDocument(e.target.value)}
|
||||
placeholder="index.html"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The file served when a directory is requested (e.g. index.html)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Error document</label>
|
||||
<Input
|
||||
value={errorDocument}
|
||||
onChange={(e) => setErrorDocument(e.target.value)}
|
||||
placeholder="404.html (optional)"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The file served when an object is not found (optional)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant={!enabled && bucket?.websiteAccess ? 'destructive' : 'default'}
|
||||
disabled={saving || (enabled && !indexDocument)}
|
||||
>
|
||||
{saving
|
||||
? 'Saving...'
|
||||
: !enabled && bucket?.websiteAccess
|
||||
? 'Disable Website'
|
||||
: 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,10 @@ import {Header} from '@/components/layout/header';
|
||||
import {ObjectsTable} from './ObjectsTable';
|
||||
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
|
||||
import {DeleteObjectDialog} from './DeleteObjectDialog';
|
||||
import {UploadProgress} from './UploadProgress';
|
||||
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload} from 'lucide-react';
|
||||
import {getBreadcrumbs} from '@/lib/file-utils';
|
||||
import type {S3Object} from '@/types';
|
||||
import type {S3Object, UploadTask} from '@/types';
|
||||
|
||||
interface ObjectBrowserViewProps {
|
||||
bucketName: string;
|
||||
@@ -23,6 +24,7 @@ interface ObjectBrowserViewProps {
|
||||
onNavigateToFolder: (path: string) => void;
|
||||
onBackToBuckets: () => void;
|
||||
onUploadFiles: (files: File[]) => Promise<boolean>;
|
||||
uploadTasks: UploadTask[];
|
||||
onDeleteObject: (key: string) => Promise<boolean>;
|
||||
onDeleteMultipleObjects: (keys: string[]) => Promise<boolean>;
|
||||
onCreateDirectory: (name: string) => Promise<boolean>;
|
||||
@@ -48,6 +50,7 @@ export function ObjectBrowserView({
|
||||
onNavigateToFolder,
|
||||
onBackToBuckets,
|
||||
onUploadFiles,
|
||||
uploadTasks,
|
||||
onDeleteObject,
|
||||
onDeleteMultipleObjects,
|
||||
onCreateDirectory,
|
||||
@@ -233,7 +236,7 @@ export function ObjectBrowserView({
|
||||
</div>
|
||||
|
||||
{/* Upload Zone */}
|
||||
{showUploadZone && (
|
||||
{showUploadZone && uploadTasks.length === 0 && (
|
||||
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-shrink-0 flex items-center justify-center">
|
||||
@@ -312,6 +315,9 @@ export function ObjectBrowserView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Progress */}
|
||||
{uploadTasks.length > 0 && <UploadProgress tasks={uploadTasks} />}
|
||||
|
||||
{/* Objects Table with Drag & Drop */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
@@ -344,6 +350,7 @@ export function ObjectBrowserView({
|
||||
)}
|
||||
|
||||
<ObjectsTable
|
||||
bucketName={bucketName}
|
||||
objects={objects}
|
||||
currentPath={currentPath}
|
||||
searchQuery={searchQuery}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import type { ObjectMetadata } from '@/types';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft, Download, Trash, Copy, File } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
|
||||
export function ObjectDetailsView() {
|
||||
const navigate = useNavigate();
|
||||
const { bucketName, '*': encodedObjectKey } = useParams();
|
||||
// Decode the object key from the URL
|
||||
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
|
||||
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bucketName || !objectKey) {
|
||||
setError('Bucket name and object key are required');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await objectsApi.getMetadata(bucketName, objectKey);
|
||||
setMetadata(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load object metadata');
|
||||
console.error('Failed to fetch object metadata:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMetadata();
|
||||
}, [bucketName, objectKey]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
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 = objectKey.split('/').pop() || 'download';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success('Download started');
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!bucketName || !objectKey) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete "${objectKey}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await objectsApi.delete(bucketName, objectKey);
|
||||
toast.success('Object deleted successfully');
|
||||
handleBackNavigation();
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackNavigation = () => {
|
||||
if (!bucketName) return;
|
||||
|
||||
// Navigate back to the bucket explorer with the appropriate prefix
|
||||
// Extract the folder path from the object key (everything before the last /)
|
||||
const folderPath = objectKey?.split('/').slice(0, -1).join('/') || '';
|
||||
const prefix = folderPath ? `${folderPath}/` : '';
|
||||
|
||||
// Navigate to the bucket view with the correct prefix
|
||||
navigate(`/buckets?bucket=${encodeURIComponent(bucketName)}${prefix ? `&prefix=${encodeURIComponent(prefix)}` : ''}`);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Header title="Object Details" />
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">Loading object details...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !metadata) {
|
||||
return (
|
||||
<div>
|
||||
<Header title="Object Details" />
|
||||
<div className="p-4 sm:p-6">
|
||||
<Button variant="outline" onClick={handleBackNavigation} className="mb-4">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-red-500">{error || 'Object not found'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileName = objectKey?.split('/').pop() || objectKey || '';
|
||||
const pathParts = objectKey?.split('/').filter(part => part) || [];
|
||||
const parentPath = pathParts.slice(0, -1).join('/');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title={fileName} />
|
||||
<div className="p-4 sm:p-6 space-y-6">
|
||||
{/* Back Button and Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={handleBackNavigation}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-500 text-red-500 hover:bg-red-500/5"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Name Header */}
|
||||
<div className="flex items-start gap-3 p-4 border-b border-border bg-card rounded-t-lg">
|
||||
<div className="mt-1">
|
||||
<File className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<h2 className="text-lg font-medium text-foreground break-all">
|
||||
{parentPath && (
|
||||
<span className="text-muted-foreground font-mono">/{parentPath}/</span>
|
||||
)}
|
||||
{fileName}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => copyToClipboard(metadata.key)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Object Details Section */}
|
||||
<div className="border border-border rounded-lg bg-card">
|
||||
<div className="p-6 border-b border-border">
|
||||
<h3 className="text-base font-semibold text-foreground">Object Details</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
|
||||
<div className="text-sm font-medium text-muted-foreground">Date Created</div>
|
||||
<div className="sm:col-span-2 text-sm text-foreground">
|
||||
{formatDate(metadata.lastModified)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
|
||||
<div className="text-sm font-medium text-muted-foreground">Type</div>
|
||||
<div className="sm:col-span-2 text-sm text-foreground">
|
||||
{metadata.contentType || 'application/octet-stream'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
|
||||
<div className="text-sm font-medium text-muted-foreground">Storage Class</div>
|
||||
<div className="sm:col-span-2 text-sm text-foreground">
|
||||
{metadata.storageClass || 'Standard'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
|
||||
<div className="text-sm font-medium text-muted-foreground">Size</div>
|
||||
<div className="sm:col-span-2 text-sm text-foreground">
|
||||
{formatBytes(metadata.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Metadata Section */}
|
||||
{metadata.metadata && Object.keys(metadata.metadata).length > 0 && (
|
||||
<div className="border border-border rounded-lg bg-card">
|
||||
<div className="p-6 border-b border-border">
|
||||
<h3 className="text-base font-semibold text-foreground">Custom Metadata</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/30">
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Key
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{Object.entries(metadata.metadata).map(([key, value]) => (
|
||||
<tr key={key} className="hover:bg-muted/30">
|
||||
<td className="px-6 py-4 text-sm font-medium text-foreground break-all">
|
||||
{key}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-foreground break-all">{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Object Preview Section */}
|
||||
<div className="border border-border rounded-lg bg-card">
|
||||
<div className="p-6 border-b border-border">
|
||||
<h3 className="text-base font-semibold text-foreground">Object Preview</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-muted-foreground">No preview available</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Checkbox} from '@/components/ui/checkbox';
|
||||
@@ -11,13 +12,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {ChevronLeft, ChevronRight, Download, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react';
|
||||
import {ChevronLeft, ChevronRight, Download, Eye, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react';
|
||||
import {Select, SelectOption} from '@/components/ui/select';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {formatRelativeTime, getFileType} from '@/lib/file-utils';
|
||||
import {formatBytes, formatRelativeTime} from '@/lib/file-utils';
|
||||
import type {S3Object} from '@/types';
|
||||
|
||||
interface ObjectsTableProps {
|
||||
bucketName: string;
|
||||
objects: S3Object[];
|
||||
currentPath: string;
|
||||
searchQuery: string;
|
||||
@@ -41,6 +42,7 @@ type SortColumn = 'name' | 'size' | 'modified';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export function ObjectsTable({
|
||||
bucketName,
|
||||
objects,
|
||||
currentPath,
|
||||
searchQuery,
|
||||
@@ -59,6 +61,7 @@ export function ObjectsTable({
|
||||
initialPageToken,
|
||||
initialItemsPerPage,
|
||||
}: ObjectsTableProps) {
|
||||
const navigate = useNavigate();
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
|
||||
@@ -117,16 +120,22 @@ export function ObjectsTable({
|
||||
return sorted;
|
||||
};
|
||||
|
||||
// Effect 1: Apply client-side filtering and sorting (NO pagination reset)
|
||||
useEffect(() => {
|
||||
const filtered = objects.filter((obj) =>
|
||||
obj.key.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
const sorted = sortObjects(filtered);
|
||||
setFilteredObjects(sorted);
|
||||
// Reset pagination when path/search changes
|
||||
|
||||
// Do NOT reset pagination - search/sort are client-side operations
|
||||
}, [searchQuery, objects, sortColumn, sortDirection]);
|
||||
|
||||
// Effect 2: Reset pagination ONLY on path navigation
|
||||
useEffect(() => {
|
||||
setPageTokens([undefined]);
|
||||
setCurrentPageIndex(0);
|
||||
}, [searchQuery, objects, sortColumn, sortDirection, currentPath]);
|
||||
}, [currentPath]);
|
||||
|
||||
// Update page tokens when we get a new next token
|
||||
useEffect(() => {
|
||||
@@ -273,14 +282,17 @@ export function ObjectsTable({
|
||||
{obj.key.replace(currentPath, '').replace('/', '')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-medium">
|
||||
<button
|
||||
onClick={() => navigate(`/buckets/${bucketName}/objects/${encodeURIComponent(obj.key)}`)}
|
||||
className="font-medium cursor-pointer hover:underline hover:text-primary"
|
||||
>
|
||||
{obj.key.replace(currentPath, '')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
{obj.isFolder ? 'Directory' : getFileType(obj.key.replace(currentPath, ''))}
|
||||
{obj.isFolder ? 'Directory' : (obj.contentType || 'application/octet-stream')}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{obj.storageClass && (
|
||||
@@ -289,56 +301,60 @@ export function ObjectsTable({
|
||||
</TableCell>
|
||||
<TableCell>{obj.isFolder ? null : formatBytes(obj.size)}</TableCell>
|
||||
<TableCell>
|
||||
{obj.lastModified ?
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="decoration-dashed decoration-1 underline underline-offset-6 cursor-pointer text-muted-foreground hover:text-foreground transition-colors">
|
||||
{new Date(obj.lastModified).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})} {new Date(obj.lastModified).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})} CET
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1 min-w-max">
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">UTC</span>
|
||||
<span className="text-sm text-white">
|
||||
{new Date(obj.lastModified).toLocaleString('en-GB', {
|
||||
{obj.lastModified ? (() => {
|
||||
const d = new Date(obj.lastModified);
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="decoration-dashed decoration-1 underline underline-offset-6 cursor-pointer text-muted-foreground hover:text-foreground transition-colors">
|
||||
{d.toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})} {d.toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
})} UTC
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Relative</span>
|
||||
<span className="text-sm text-white">
|
||||
{formatRelativeTime(new Date(obj.lastModified))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Timestamp</span>
|
||||
<span className="text-sm text-white font-mono">
|
||||
{new Date(obj.lastModified).toISOString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>: null}
|
||||
})} CET
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1 min-w-max">
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">UTC</span>
|
||||
<span className="text-sm text-white">
|
||||
{d.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
})} UTC
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Relative</span>
|
||||
<span className="text-sm text-white">
|
||||
{formatRelativeTime(d)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Timestamp</span>
|
||||
<span className="text-sm text-white font-mono">
|
||||
{d.toISOString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})() : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!obj.isFolder && (
|
||||
@@ -349,6 +365,10 @@ export function ObjectsTable({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/buckets/${bucketName}/objects/${encodeURIComponent(obj.key)}`)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { CheckCircle, Upload, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { UploadTask } from '@/types';
|
||||
|
||||
interface UploadProgressProps {
|
||||
tasks: UploadTask[];
|
||||
}
|
||||
|
||||
export function UploadProgress({ tasks }: UploadProgressProps) {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
const completedCount = tasks.filter(t => t.status === 'completed').length;
|
||||
const errorCount = tasks.filter(t => t.status === 'error').length;
|
||||
const totalCount = tasks.length;
|
||||
const processedCount = completedCount + errorCount;
|
||||
const allDone = processedCount === totalCount;
|
||||
|
||||
// Find currently uploading file
|
||||
const currentFile = tasks.find(t => t.status === 'uploading');
|
||||
const currentFileName = currentFile?.key.split('/').pop() || currentFile?.key || 'Processing...';
|
||||
|
||||
// File-based progress plus contribution from current upload
|
||||
const baseProgress = (processedCount / totalCount) * 100;
|
||||
const currentFileContribution = currentFile
|
||||
? (currentFile.progress / 100) * (1 / totalCount) * 100
|
||||
: 0;
|
||||
const overallProgress = Math.min(baseProgress + currentFileContribution, 100);
|
||||
|
||||
return (
|
||||
<Card className="border-primary/20 shadow-md">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
{/* Header with icon and status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg flex-shrink-0 ${
|
||||
allDone
|
||||
? 'bg-green-500/10 dark:bg-green-500/20'
|
||||
: errorCount > 0
|
||||
? 'bg-yellow-500/10 dark:bg-yellow-500/20'
|
||||
: 'bg-primary/10 dark:bg-primary/20'
|
||||
}`}>
|
||||
{allDone ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-500" />
|
||||
) : errorCount > 0 ? (
|
||||
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-500" />
|
||||
) : (
|
||||
<Loader2 className="h-5 w-5 text-primary animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-sm">
|
||||
{allDone ? 'Upload Complete' : 'Uploading Files'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{processedCount} of {totalCount} files
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className={`text-2xl font-bold tabular-nums ${
|
||||
allDone ? 'text-green-600 dark:text-green-500' : 'text-primary'
|
||||
}`}>
|
||||
{Math.round(overallProgress)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar with gradient */}
|
||||
<div className="space-y-2">
|
||||
{!allDone && currentFile && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Upload className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="truncate flex-1" title={currentFileName}>
|
||||
{currentFileName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-full bg-secondary rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ease-out relative bg-green-500 dark:bg-green-600`}
|
||||
style={{ width: `${overallProgress}%` }}
|
||||
>
|
||||
{/* Animated shimmer effect */}
|
||||
{!allDone && overallProgress > 0 && (
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 dark:via-white/25 to-transparent animate-shimmer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error indicator with icon */}
|
||||
{errorCount > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs bg-red-500/10 dark:bg-red-500/20 text-red-700 dark:text-red-400 rounded-md px-3 py-2 border border-red-200 dark:border-red-900/50">
|
||||
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
{errorCount} file{errorCount > 1 ? 's' : ''} failed to upload
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success message */}
|
||||
{allDone && errorCount === 0 && (
|
||||
<div className="flex items-center gap-2 text-xs bg-green-500/10 dark:bg-green-500/20 text-green-700 dark:text-green-400 rounded-md px-3 py-2 border border-green-200 dark:border-green-900/50">
|
||||
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>All files uploaded successfully</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip} from 'recharts';
|
||||
import type {BucketUsage} from '@/types';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {formatBytes} from '@/lib/file-utils';
|
||||
import {chartColorPalette, getTextColor, getTooltipStyle} from '@/lib/chart-colors';
|
||||
|
||||
interface BucketUsageChartProps {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import type {ClusterHealth} from '@/types';
|
||||
import {getGridColor, getTextColor, getTooltipStyle, grafanaColors} from '@/lib/chart-colors';
|
||||
|
||||
interface ClusterHealthChartProps {
|
||||
data: ClusterHealth;
|
||||
}
|
||||
|
||||
export function ClusterHealthChart({ data }: ClusterHealthChartProps) {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDarkMode = () => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
};
|
||||
|
||||
checkDarkMode();
|
||||
|
||||
const observer = new MutationObserver(checkDarkMode);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const colors = isDark ? grafanaColors.dark : grafanaColors.light;
|
||||
const textColor = getTextColor(isDark);
|
||||
const gridColor = getGridColor(isDark);
|
||||
const tooltipStyle = getTooltipStyle(isDark);
|
||||
const unhealthyColor = isDark ? '#e8e8e8' : '#d1d5db';
|
||||
|
||||
const chartData = [
|
||||
{
|
||||
metric: 'Nodes',
|
||||
healthy: data.storageNodesUp,
|
||||
unhealthy: data.storageNodes - data.storageNodesUp,
|
||||
},
|
||||
{
|
||||
metric: 'Partitions',
|
||||
healthy: data.partitionsAllOk,
|
||||
unhealthy: data.partitions - data.partitionsAllOk,
|
||||
},
|
||||
{
|
||||
metric: 'Connected',
|
||||
healthy: data.connectedNodes,
|
||||
unhealthy: data.knownNodes - data.connectedNodes,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
|
||||
<XAxis dataKey="metric" stroke={textColor} />
|
||||
<YAxis stroke={textColor} />
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle as React.CSSProperties}
|
||||
labelStyle={{ color: textColor }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: textColor }} />
|
||||
<Bar dataKey="healthy" stackId="a" fill={colors.green} name="Healthy" />
|
||||
<Bar dataKey="unhealthy" stackId="a" fill={unhealthyColor} name="Unhealthy" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import type { RequestMetrics } from '@/types';
|
||||
import { grafanaColors, getTextColor, getGridColor, getTooltipStyle } from '@/lib/chart-colors';
|
||||
|
||||
interface RequestMetricsChartProps {
|
||||
data: RequestMetrics;
|
||||
}
|
||||
|
||||
export function RequestMetricsChart({ data }: RequestMetricsChartProps) {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDarkMode = () => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
};
|
||||
|
||||
checkDarkMode();
|
||||
|
||||
const observer = new MutationObserver(checkDarkMode);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const colors = isDark ? grafanaColors.dark : grafanaColors.light;
|
||||
const textColor = getTextColor(isDark);
|
||||
const gridColor = getGridColor(isDark);
|
||||
const tooltipStyle = getTooltipStyle(isDark);
|
||||
|
||||
const chartData = [
|
||||
{
|
||||
name: 'Requests (24h)',
|
||||
GET: data.getRequests,
|
||||
PUT: data.putRequests,
|
||||
DELETE: data.deleteRequests,
|
||||
LIST: data.listRequests,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
|
||||
<XAxis dataKey="name" stroke={textColor} />
|
||||
<YAxis stroke={textColor} />
|
||||
<Tooltip
|
||||
formatter={(value) => (value as number).toLocaleString()}
|
||||
contentStyle={tooltipStyle as React.CSSProperties}
|
||||
labelStyle={{ color: textColor }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: textColor }} />
|
||||
<Bar dataKey="GET" stackId="a" fill={colors.blue} />
|
||||
<Bar dataKey="PUT" stackId="a" fill={colors.green} />
|
||||
<Bar dataKey="DELETE" stackId="a" fill={colors.red} />
|
||||
<Bar dataKey="LIST" stackId="a" fill={colors.orange} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import {Link, useLocation} from 'react-router-dom';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {Database, Key, LayoutDashboard, Server} from 'lucide-react';
|
||||
import {Database, Key, LayoutDashboard, LogOut, Server, User} from 'lucide-react';
|
||||
import {useAuthStore} from '@/store/auth-store';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { healthApi, garageApi } from '@/lib/api';
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
@@ -38,6 +42,29 @@ interface SidebarProps {
|
||||
|
||||
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const { user, config, logout } = useAuthStore();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
const { data: uiVersion } = useQuery({
|
||||
queryKey: ['ui-version'],
|
||||
queryFn: healthApi.getVersion,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: nodeInfo } = useQuery({
|
||||
queryKey: ['garage-version'],
|
||||
queryFn: () => garageApi.getNodeInfo('self'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const garageVersion = nodeInfo
|
||||
? Object.values(nodeInfo.success)[0]?.garageVersion
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -76,17 +103,37 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{/*<div className="border-t p-4">*/}
|
||||
{/* <div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">*/}
|
||||
{/* <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">*/}
|
||||
{/* AD*/}
|
||||
{/* </div>*/}
|
||||
{/* <div className="flex-1 overflow-hidden">*/}
|
||||
{/* <p className="text-sm font-medium truncate">Admin User</p>*/}
|
||||
{/* <p className="text-xs text-muted-foreground truncate">admin@garage.local</p>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/*</div>*/}
|
||||
{config && (config.admin.enabled || config.oidc.enabled) && user && (
|
||||
<div className="border-t p-4 space-y-2">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
|
||||
<User className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="text-sm font-medium truncate">{user.name || user.username}</p>
|
||||
{user.email && (
|
||||
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(uiVersion || garageVersion) && (
|
||||
<div className="px-4 pb-3 text-xs text-muted-foreground text-center">
|
||||
{uiVersion && `UI ${uiVersion}`}
|
||||
{uiVersion && garageVersion && ' | '}
|
||||
{garageVersion && `Garage ${garageVersion}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, checked, onCheckedChange, ...props }, ref) => {
|
||||
return (
|
||||
<label className="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-6 w-11 rounded-full border 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',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<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'})` }}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
);
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export { Switch };
|
||||
@@ -1,3 +1,2 @@
|
||||
export { useDashboardData } from './useApi';
|
||||
export { useBuckets } from './useBuckets';
|
||||
export { useDashboardData, useBuckets } from './useApi';
|
||||
export { useBucketObjects } from './useBucketObjects';
|
||||
|
||||
@@ -3,9 +3,6 @@ import { bucketsApi, objectsApi, accessApi, garageApi, analyticsApi } from '@/li
|
||||
import { queryKeys } from '@/lib/query-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ===========================
|
||||
// Bucket Hooks
|
||||
// ===========================
|
||||
|
||||
export function useBuckets() {
|
||||
return useQuery({
|
||||
@@ -66,9 +63,6 @@ export function useGrantBucketPermission() {
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Object Hooks
|
||||
// ===========================
|
||||
|
||||
export function useObjects(bucket: string, prefix?: string, enabled = true) {
|
||||
return useQuery({
|
||||
@@ -138,9 +132,6 @@ export function useDeleteMultipleObjects() {
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Access Key Hooks
|
||||
// ===========================
|
||||
|
||||
export function useAccessKeys() {
|
||||
return useQuery({
|
||||
@@ -197,9 +188,6 @@ export function useUpdateAccessKey() {
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Cluster Hooks
|
||||
// ===========================
|
||||
|
||||
export function useClusterHealth() {
|
||||
return useQuery({
|
||||
@@ -225,9 +213,6 @@ export function useClusterStatistics() {
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Dashboard Hooks
|
||||
// ===========================
|
||||
|
||||
export function useDashboardMetrics() {
|
||||
return useQuery({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import type { S3Object } from '@/types';
|
||||
import type { S3Object, UploadTask } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useBucketObjects(bucketName: string | null, currentPath: string = '') {
|
||||
@@ -14,6 +14,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [previousPath, setPreviousPath] = useState<string>(currentPath);
|
||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||
|
||||
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
|
||||
if (!bucketName) return;
|
||||
@@ -57,41 +58,103 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
const uploadFiles = useCallback(async (files: File[]) => {
|
||||
if (!bucketName) return false;
|
||||
|
||||
try {
|
||||
// Check if files are from a folder upload
|
||||
const hasRelativePaths = files.some((file: any) => file.webkitRelativePath);
|
||||
// Check if files are from a folder upload
|
||||
const hasRelativePaths = files.some((file: any) => file.webkitRelativePath);
|
||||
|
||||
// Get unique folders from the files
|
||||
const folders = new Set<string>();
|
||||
files.forEach((file: any) => {
|
||||
if (file.webkitRelativePath) {
|
||||
const parts = file.webkitRelativePath.split('/');
|
||||
if (parts.length > 1) {
|
||||
folders.add(parts[0]);
|
||||
}
|
||||
// Get unique folders from the files
|
||||
const folders = new Set<string>();
|
||||
files.forEach((file: any) => {
|
||||
if (file.webkitRelativePath) {
|
||||
const parts = file.webkitRelativePath.split('/');
|
||||
if (parts.length > 1) {
|
||||
folders.add(parts[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize upload tasks
|
||||
const tasks: UploadTask[] = files.map((file, index) => {
|
||||
const relativePath = (file as any).webkitRelativePath || file.name;
|
||||
const key = currentPath ? `${currentPath}${relativePath}` : relativePath;
|
||||
return {
|
||||
id: `${Date.now()}-${index}`,
|
||||
file,
|
||||
key,
|
||||
bucket: bucketName,
|
||||
progress: 0,
|
||||
status: 'pending' as const,
|
||||
};
|
||||
});
|
||||
|
||||
setUploadTasks(tasks);
|
||||
|
||||
// Upload files with progress tracking and error handling
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// Upload files one by one
|
||||
const concurrency = 1;
|
||||
const uploadPromises: Promise<void>[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i += concurrency) {
|
||||
const batch = tasks.slice(i, Math.min(i + concurrency, tasks.length));
|
||||
|
||||
const batchPromises = batch.map(async (task) => {
|
||||
try {
|
||||
// Update task status to uploading
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'uploading' as const } : t
|
||||
));
|
||||
|
||||
await objectsApi.upload(bucketName, task.key, task.file, (progress) => {
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, progress } : t
|
||||
));
|
||||
});
|
||||
|
||||
// Update task status to completed
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t
|
||||
));
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
// Update task status to error but continue with other uploads
|
||||
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'error' as const, error: errorMessage } : t
|
||||
));
|
||||
errorCount++;
|
||||
console.error(`Failed to upload ${task.key}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
// Use webkitRelativePath if available (for folder uploads), otherwise use file.name
|
||||
const relativePath = (file as any).webkitRelativePath || file.name;
|
||||
const key = currentPath ? `${currentPath}${relativePath}` : relativePath;
|
||||
await objectsApi.upload(bucketName, key, file);
|
||||
}
|
||||
uploadPromises.push(...batchPromises);
|
||||
await Promise.all(batchPromises);
|
||||
}
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
// Show summary toast
|
||||
if (errorCount === 0) {
|
||||
if (hasRelativePaths && folders.size > 0) {
|
||||
const folderNames = Array.from(folders).join(', ');
|
||||
toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''} from ${folders.size} folder${folders.size > 1 ? 's' : ''} (${folderNames})`);
|
||||
toast.success(`Successfully uploaded ${successCount} file${successCount > 1 ? 's' : ''} from ${folders.size} folder${folders.size > 1 ? 's' : ''} (${folderNames})`);
|
||||
} else {
|
||||
toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''}`);
|
||||
toast.success(`Successfully uploaded ${successCount} file${successCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return false;
|
||||
} else if (successCount > 0) {
|
||||
toast.warning(`Uploaded ${successCount} file${successCount > 1 ? 's' : ''}, ${errorCount} failed`);
|
||||
} else {
|
||||
toast.error(`Failed to upload ${errorCount} file${errorCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
// Clear upload tasks after a delay
|
||||
setTimeout(() => {
|
||||
setUploadTasks([]);
|
||||
}, 3000);
|
||||
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return successCount > 0;
|
||||
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
|
||||
|
||||
const deleteObject = useCallback(async (key: string) => {
|
||||
@@ -160,6 +223,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
setItemsPerPage,
|
||||
fetchObjects,
|
||||
uploadFiles,
|
||||
uploadTasks,
|
||||
deleteObject,
|
||||
deleteMultipleObjects,
|
||||
createDirectory,
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { bucketsApi } from '@/lib/api';
|
||||
import type { Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useBuckets() {
|
||||
const [buckets, setBuckets] = useState<Bucket[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchBuckets = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await bucketsApi.list();
|
||||
setBuckets(data);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch buckets:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBuckets();
|
||||
}, [fetchBuckets]);
|
||||
|
||||
const createBucket = useCallback(async (name: string, region?: string) => {
|
||||
try {
|
||||
await bucketsApi.create(name, region);
|
||||
toast.success(`Bucket "${name}" created successfully`);
|
||||
await fetchBuckets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Create bucket error:', error);
|
||||
return false;
|
||||
}
|
||||
}, [fetchBuckets]);
|
||||
|
||||
const deleteBucket = useCallback(async (name: string) => {
|
||||
try {
|
||||
await bucketsApi.delete(name);
|
||||
toast.success(`Bucket "${name}" deleted successfully`);
|
||||
await fetchBuckets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Delete bucket error:', error);
|
||||
return false;
|
||||
}
|
||||
}, [fetchBuckets]);
|
||||
|
||||
const grantPermission = useCallback(async (
|
||||
bucketName: string,
|
||||
accessKeyId: string,
|
||||
permissions: { read: boolean; write: boolean; owner: boolean }
|
||||
) => {
|
||||
try {
|
||||
await bucketsApi.grantPermission(bucketName, accessKeyId, permissions);
|
||||
toast.success('Permissions granted successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Grant permission error:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
buckets,
|
||||
isLoading,
|
||||
error,
|
||||
fetchBuckets,
|
||||
createBucket,
|
||||
deleteBucket,
|
||||
grantPermission,
|
||||
};
|
||||
}
|
||||
+122
-7
@@ -2,6 +2,7 @@ import axios from 'axios';
|
||||
import {toast} from 'sonner';
|
||||
import type {
|
||||
AccessKey,
|
||||
ApiResponse,
|
||||
Bucket,
|
||||
BucketDetails,
|
||||
ClusterHealth,
|
||||
@@ -15,9 +16,24 @@ import type {
|
||||
S3Object,
|
||||
StorageMetrics,
|
||||
} from '@/types';
|
||||
import type { AuthUser } from '@/types/auth';
|
||||
|
||||
// Helper function to encode object keys for URLs
|
||||
// Encodes the entire key including slashes to ensure proper handling of special characters
|
||||
const encodeObjectKey = (key: string): string => {
|
||||
return encodeURIComponent(key);
|
||||
};
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api',
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Separate axios instance for auth endpoints (which are not under /api)
|
||||
const authApiClient = axios.create({
|
||||
baseURL: '/auth',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -31,6 +47,14 @@ api.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
authApiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
// If response has success=false in data, treat it as an error
|
||||
@@ -50,6 +74,19 @@ api.interceptors.response.use(
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
// Handle 401 Unauthorized - redirect to login
|
||||
if (error.response?.status === 401) {
|
||||
// Clear auth token
|
||||
localStorage.removeItem('auth-token');
|
||||
|
||||
// Only redirect if not already on login page
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Handle axios errors
|
||||
if (error.response) {
|
||||
// Server responded with error status
|
||||
@@ -84,6 +121,52 @@ api.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
getConfig: async () => {
|
||||
const response = await authApiClient.get<{
|
||||
admin: { enabled: boolean };
|
||||
oidc: { enabled: boolean; provider?: string };
|
||||
}>('/config');
|
||||
return response;
|
||||
},
|
||||
|
||||
loginAdmin: async (username: string, password: string) => {
|
||||
const response = await authApiClient.post<{ success: boolean; token: string; user: AuthUser }>('/login', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
me: async () => {
|
||||
const response = await authApiClient.get<{ success: boolean; user: AuthUser }>('/me');
|
||||
return response;
|
||||
},
|
||||
|
||||
logoutAdmin: async () => {
|
||||
// For admin, just clear local storage (no server logout needed)
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
logoutOIDC: async () => {
|
||||
const response = await authApiClient.post('/oidc/logout');
|
||||
return response;
|
||||
},
|
||||
|
||||
loginOIDC: () => {
|
||||
window.location.href = '/auth/oidc/login';
|
||||
},
|
||||
};
|
||||
|
||||
// Health API
|
||||
export const healthApi = {
|
||||
getVersion: async (): Promise<string> => {
|
||||
const response = await api.get('/v1/health');
|
||||
return response.data.data.version as string;
|
||||
},
|
||||
};
|
||||
|
||||
// Bucket API
|
||||
export const bucketsApi = {
|
||||
list: async (): Promise<Bucket[]> => {
|
||||
@@ -118,6 +201,17 @@ export const bucketsApi = {
|
||||
updateSettings: async (name: string, settings: Partial<BucketDetails>): Promise<void> => {
|
||||
await api.patch(`/v1/buckets/${name}/settings`, settings);
|
||||
},
|
||||
|
||||
updateBucketWebsite: async (
|
||||
name: string,
|
||||
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
|
||||
) => {
|
||||
const response = await api.put<ApiResponse<any>>(
|
||||
`/v1/buckets/${encodeURIComponent(name)}/website`,
|
||||
payload
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Objects API
|
||||
@@ -139,6 +233,7 @@ export const objectsApi = {
|
||||
size: obj.size,
|
||||
lastModified: obj.last_modified,
|
||||
etag: obj.etag,
|
||||
contentType: obj.content_type,
|
||||
storageClass: obj.storage_class,
|
||||
isFolder: false,
|
||||
})) || [];
|
||||
@@ -161,23 +256,38 @@ export const objectsApi = {
|
||||
},
|
||||
|
||||
get: async (bucket: string, key: string): Promise<Blob> => {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`, {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getMetadata: async (bucket: string, key: string): Promise<ObjectMetadata> => {
|
||||
const response = await api.head(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`);
|
||||
return response.data.data;
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/metadata`);
|
||||
const data = response.data.data;
|
||||
return {
|
||||
key: data.key,
|
||||
size: data.size,
|
||||
lastModified: data.last_modified,
|
||||
contentType: data.content_type,
|
||||
etag: data.etag,
|
||||
storageClass: data.storage_class,
|
||||
metadata: data.metadata,
|
||||
};
|
||||
},
|
||||
|
||||
upload: async (bucket: string, key: string, file: File): Promise<void> => {
|
||||
upload: async (bucket: string, key: string, file: File, onProgress?: (progress: number) => void): Promise<void> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('key', key);
|
||||
await api.post(`/v1/buckets/${bucket}/objects`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress(progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -194,7 +304,7 @@ export const objectsApi = {
|
||||
},
|
||||
|
||||
delete: async (bucket: string, key: string): Promise<void> => {
|
||||
await api.delete(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`);
|
||||
await api.delete(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`);
|
||||
},
|
||||
|
||||
deleteMultiple: async (bucket: string, keys: string[], prefix?: string): Promise<void> => {
|
||||
@@ -203,7 +313,7 @@ export const objectsApi = {
|
||||
},
|
||||
|
||||
getPresignedUrl: async (bucket: string, key: string, expiresIn: number = 3600): Promise<string> => {
|
||||
const response = await api.post(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}/presign`, {}, {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/presign`, {
|
||||
params: { expires_in: expiresIn }
|
||||
});
|
||||
return response.data.data.url;
|
||||
@@ -222,6 +332,11 @@ export const accessApi = {
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getSecretKey: async (accessKey: string): Promise<string> => {
|
||||
const response = await api.get(`/v1/users/${accessKey}/secret`);
|
||||
return response.data.data.secretKey;
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
createKey: async (name: string, permissions?: any[]): Promise<AccessKey> => {
|
||||
const response = await api.post('/v1/users', { name, permissions });
|
||||
|
||||
@@ -98,3 +98,18 @@ export function formatRelativeTime(date: Date): string {
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) !== 1 ? 's' : ''} ago`;
|
||||
return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable size
|
||||
*/
|
||||
export function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
@@ -5,18 +5,6 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
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 {accessApi, bucketsApi} from '@/lib/api';
|
||||
@@ -46,6 +46,7 @@ export function AccessControl() {
|
||||
const [createPermissionWrite, setCreatePermissionWrite] = useState(false);
|
||||
const [createPermissionOwner, setCreatePermissionOwner] = useState(false);
|
||||
const [createGrantPermissions, setCreateGrantPermissions] = useState(false);
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<AccessKey | null>(null);
|
||||
|
||||
// Edit permissions state
|
||||
const [editPermissionsDialogOpen, setEditPermissionsDialogOpen] = useState(false);
|
||||
@@ -57,18 +58,25 @@ export function AccessControl() {
|
||||
const [permissionOwner, setPermissionOwner] = useState(false);
|
||||
|
||||
// Key settings state (activation/expiration)
|
||||
// const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
// const [settingsKey, setSettingsKey] = useState<AccessKey | null>(null);
|
||||
// const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active');
|
||||
// const [expirationDate, setExpirationDate] = useState<string>('');
|
||||
// const [neverExpires, setNeverExpires] = useState(true);
|
||||
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [settingsKey, setSettingsKey] = useState<AccessKey | null>(null);
|
||||
const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active');
|
||||
const [expirationDate, setExpirationDate] = useState<string>('');
|
||||
const [neverExpires, setNeverExpires] = useState(true);
|
||||
|
||||
// Secret key dialog state
|
||||
const [secretKeyDialogOpen, setSecretKeyDialogOpen] = useState(false);
|
||||
const [revealedSecretKey, setRevealedSecretKey] = useState<string>('');
|
||||
const [isLoadingSecretKey, setIsLoadingSecretKey] = useState(false);
|
||||
|
||||
// Key details dialog state
|
||||
const [keyDetailsDialogOpen, setKeyDetailsDialogOpen] = useState(false);
|
||||
const [viewingKey, setViewingKey] = useState<AccessKey | null>(null);
|
||||
const [detailsSecretKey, setDetailsSecretKey] = useState<string>('');
|
||||
const [isLoadingDetailsSecretKey, setIsLoadingDetailsSecretKey] = useState(false);
|
||||
const [copiedAccessKeyId, setCopiedAccessKeyId] = useState(false);
|
||||
const [copiedSecretKey, setCopiedSecretKey] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchKeys = async () => {
|
||||
try {
|
||||
@@ -120,13 +128,8 @@ export function AccessControl() {
|
||||
}
|
||||
}
|
||||
|
||||
setCreateDialogOpen(false);
|
||||
setNewKeyName('');
|
||||
setCreateSelectedBucket('');
|
||||
setCreatePermissionRead(false);
|
||||
setCreatePermissionWrite(false);
|
||||
setCreatePermissionOwner(false);
|
||||
setCreateGrantPermissions(false);
|
||||
// Store the newly created key to show the secret key
|
||||
setNewlyCreatedKey(newKey);
|
||||
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
@@ -138,6 +141,17 @@ export function AccessControl() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseCreateDialog = () => {
|
||||
setCreateDialogOpen(false);
|
||||
setNewKeyName('');
|
||||
setCreateSelectedBucket('');
|
||||
setCreatePermissionRead(false);
|
||||
setCreatePermissionWrite(false);
|
||||
setCreatePermissionOwner(false);
|
||||
setCreateGrantPermissions(false);
|
||||
setNewlyCreatedKey(null);
|
||||
};
|
||||
|
||||
const handleOpenCreateDialog = async () => {
|
||||
setCreateDialogOpen(true);
|
||||
setNewKeyName('');
|
||||
@@ -222,6 +236,23 @@ export function AccessControl() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevealSecretKey = async (key: AccessKey) => {
|
||||
setSelectedKey(key);
|
||||
setIsLoadingSecretKey(true);
|
||||
setSecretKeyDialogOpen(true);
|
||||
setRevealedSecretKey('');
|
||||
|
||||
try {
|
||||
const secretKey = await accessApi.getSecretKey(key.accessKeyId);
|
||||
setRevealedSecretKey(secretKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch secret key:', error);
|
||||
setSecretKeyDialogOpen(false);
|
||||
} finally {
|
||||
setIsLoadingSecretKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditPermissions = async (key: AccessKey) => {
|
||||
setEditingKey(key);
|
||||
setEditPermissionsDialogOpen(true);
|
||||
@@ -312,6 +343,25 @@ export function AccessControl() {
|
||||
return perms.join(', ') || 'None';
|
||||
};
|
||||
|
||||
const handleRowClick = async (key: AccessKey) => {
|
||||
setViewingKey(key);
|
||||
setKeyDetailsDialogOpen(true);
|
||||
setDetailsSecretKey('');
|
||||
setIsLoadingDetailsSecretKey(true);
|
||||
setCopiedAccessKeyId(false);
|
||||
setCopiedSecretKey(false);
|
||||
|
||||
// Fetch the secret key immediately
|
||||
try {
|
||||
const secretKey = await accessApi.getSecretKey(key.accessKeyId);
|
||||
setDetailsSecretKey(secretKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch secret key:', error);
|
||||
} finally {
|
||||
setIsLoadingDetailsSecretKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header
|
||||
@@ -319,11 +369,6 @@ export function AccessControl() {
|
||||
/>
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
<Tabs defaultValue="keys">
|
||||
<TabsList className="w-full sm:w-auto">
|
||||
<TabsTrigger value="keys" className="flex-1 sm:flex-initial">API Keys</TabsTrigger>
|
||||
<TabsTrigger value="policies" className="flex-1 sm:flex-initial">Bucket Policies</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="keys" className="space-y-4 sm:space-y-6 mt-4 sm:mt-6">
|
||||
{/* Stats */}
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -389,7 +434,6 @@ export function AccessControl() {
|
||||
<TableHead className="hidden sm:table-cell">Access Key ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Created</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Last Used</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Permissions</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
@@ -397,7 +441,7 @@ export function AccessControl() {
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<TableCell colSpan={6} className="text-center py-12">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Loading API keys...</span>
|
||||
@@ -406,24 +450,36 @@ export function AccessControl() {
|
||||
</TableRow>
|
||||
) : filteredKeys.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery ? 'No keys found matching your search' : 'No API keys yet'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredKeys.map((key) => (
|
||||
<TableRow key={key.accessKeyId}>
|
||||
<TableRow
|
||||
key={key.accessKeyId}
|
||||
onClick={() => handleRowClick(key)}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<TableCell className="font-medium truncate max-w-[150px]">{key.name}</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[150px] block">
|
||||
<code
|
||||
className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[150px] block cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(key.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}}
|
||||
>
|
||||
{key.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(key.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}}
|
||||
@@ -438,9 +494,6 @@ export function AccessControl() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">{formatDate(key.createdAt)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{key.lastUsed ? formatDate(key.lastUsed) : 'Never'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.permissions.slice(0, 2).map((perm, idx) => (
|
||||
@@ -458,7 +511,7 @@ export function AccessControl() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon">
|
||||
@@ -466,6 +519,11 @@ export function AccessControl() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleRevealSecretKey(key)}>
|
||||
<Key className="h-4 w-4" />
|
||||
View Secret Key
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleOpenEditPermissions(key)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit Permissions
|
||||
@@ -505,142 +563,218 @@ export function AccessControl() {
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="policies" className="space-y-6 mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bucket Policies</CardTitle>
|
||||
<CardDescription>
|
||||
Manage resource-based policies for your buckets
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground text-center py-12">
|
||||
Bucket policy editor coming soon...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Create Key Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<Dialog open={createDialogOpen} onOpenChange={handleCloseCreateDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new API key with optional bucket permissions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<Input
|
||||
placeholder="My Application Key"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A friendly name to identify this API key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Optional: Grant permissions during creation */}
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="grant-permissions-on-create"
|
||||
checked={createGrantPermissions}
|
||||
onCheckedChange={(checked) => {
|
||||
setCreateGrantPermissions(checked as boolean);
|
||||
if (!checked) {
|
||||
setCreateSelectedBucket('');
|
||||
setCreatePermissionRead(false);
|
||||
setCreatePermissionWrite(false);
|
||||
setCreatePermissionOwner(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium">Grant bucket permissions now</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can also grant permissions later from the Edit Permissions menu
|
||||
</p>
|
||||
|
||||
{createGrantPermissions && (
|
||||
<div className="space-y-4 pl-6 pt-2">
|
||||
{/* Bucket Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Select Bucket</label>
|
||||
<Select
|
||||
value={createSelectedBucket}
|
||||
onChange={(value) => setCreateSelectedBucket(value)}
|
||||
{newlyCreatedKey ? (
|
||||
// Success state - show the secret key
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key Created Successfully</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save your secret access key now. You won't be able to see it again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<div className="text-sm text-muted-foreground">{newlyCreatedKey.name}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Access Key ID</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(newlyCreatedKey.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}}
|
||||
>
|
||||
<SelectOption value="">-- Select a bucket --</SelectOption>
|
||||
{createAvailableBuckets.map((bucket) => (
|
||||
<SelectOption key={bucket.name} value={bucket.name}>
|
||||
{bucket.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
{newlyCreatedKey.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(newlyCreatedKey.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Secret Access Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (newlyCreatedKey.secretKey) {
|
||||
navigator.clipboard.writeText(newlyCreatedKey.secretKey);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{newlyCreatedKey.secretKey}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (newlyCreatedKey.secretKey) {
|
||||
navigator.clipboard.writeText(newlyCreatedKey.secretKey);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 bg-orange-100 border-orange-300 dark:bg-orange-950/20 dark:border-orange-900">
|
||||
<div className="flex gap-2">
|
||||
<ShieldX className="h-5 w-5 text-orange-700 dark:text-orange-500 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-orange-950 dark:text-orange-200">
|
||||
Important: Save This Key Now
|
||||
</p>
|
||||
<p className="text-xs text-orange-900 dark:text-orange-300">
|
||||
This is the only time you'll see the secret access key. Make sure to copy and save it securely.
|
||||
If you lose it, you'll need to create a new key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleCloseCreateDialog}>
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
// Creation form
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new API key with optional bucket permissions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<Input
|
||||
placeholder="My Application Key"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A friendly name to identify this API key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
{createSelectedBucket && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Permissions</label>
|
||||
<div className="space-y-2 border rounded-lg p-3">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-read"
|
||||
checked={createPermissionRead}
|
||||
onCheckedChange={(checked) => setCreatePermissionRead(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Read</span>
|
||||
<p className="text-xs text-muted-foreground">GetObject, HeadObject, ListObjects</p>
|
||||
</div>
|
||||
</label>
|
||||
{/* Optional: Grant permissions during creation */}
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="grant-permissions-on-create"
|
||||
checked={createGrantPermissions}
|
||||
onCheckedChange={(checked) => {
|
||||
setCreateGrantPermissions(checked as boolean);
|
||||
if (!checked) {
|
||||
setCreateSelectedBucket('');
|
||||
setCreatePermissionRead(false);
|
||||
setCreatePermissionWrite(false);
|
||||
setCreatePermissionOwner(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium">Grant bucket permissions now</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can also grant permissions later from the Edit Permissions menu
|
||||
</p>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-write"
|
||||
checked={createPermissionWrite}
|
||||
onCheckedChange={(checked) => setCreatePermissionWrite(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Write</span>
|
||||
<p className="text-xs text-muted-foreground">PutObject, DeleteObject</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-owner"
|
||||
checked={createPermissionOwner}
|
||||
onCheckedChange={(checked) => setCreatePermissionOwner(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Owner</span>
|
||||
<p className="text-xs text-muted-foreground">DeleteBucket, PutBucketPolicy</p>
|
||||
</div>
|
||||
</label>
|
||||
{createGrantPermissions && (
|
||||
<div className="space-y-4 pl-6 pt-2">
|
||||
{/* Bucket Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Select Bucket</label>
|
||||
<Select
|
||||
value={createSelectedBucket}
|
||||
onChange={(value) => setCreateSelectedBucket(value)}
|
||||
>
|
||||
<SelectOption value="">-- Select a bucket --</SelectOption>
|
||||
{createAvailableBuckets.map((bucket) => (
|
||||
<SelectOption key={bucket.name} value={bucket.name}>
|
||||
{bucket.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
{createSelectedBucket && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Permissions</label>
|
||||
<div className="space-y-2 border rounded-lg p-3">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-read"
|
||||
checked={createPermissionRead}
|
||||
onCheckedChange={(checked) => setCreatePermissionRead(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Read</span>
|
||||
<p className="text-xs text-muted-foreground">GetObject, HeadObject, ListObjects</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-write"
|
||||
checked={createPermissionWrite}
|
||||
onCheckedChange={(checked) => setCreatePermissionWrite(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Write</span>
|
||||
<p className="text-xs text-muted-foreground">PutObject, DeleteObject</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
id="create-permission-owner"
|
||||
checked={createPermissionOwner}
|
||||
onCheckedChange={(checked) => setCreatePermissionOwner(checked as boolean)}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Owner</span>
|
||||
<p className="text-xs text-muted-foreground">DeleteBucket, PutBucketPolicy</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateKey} disabled={!newKeyName}>
|
||||
Create Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCloseCreateDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateKey} disabled={!newKeyName}>
|
||||
Create Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -665,6 +799,95 @@ export function AccessControl() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Secret Key Dialog */}
|
||||
<Dialog open={secretKeyDialogOpen} onOpenChange={setSecretKeyDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Secret Access Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy your secret access key now. For security reasons, it cannot be viewed again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<div className="text-sm text-muted-foreground">{selectedKey?.name}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Access Key ID</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (selectedKey?.accessKeyId) {
|
||||
navigator.clipboard.writeText(selectedKey.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedKey?.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (selectedKey?.accessKeyId) {
|
||||
navigator.clipboard.writeText(selectedKey.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Secret Access Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoadingSecretKey ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground flex-1 bg-muted px-3 py-2 rounded">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Loading secret key...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (revealedSecretKey) {
|
||||
navigator.clipboard.writeText(revealedSecretKey);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{revealedSecretKey}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (revealedSecretKey) {
|
||||
navigator.clipboard.writeText(revealedSecretKey);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
disabled={!revealedSecretKey}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setSecretKeyDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Key Settings Dialog */}
|
||||
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
@@ -736,26 +959,6 @@ export function AccessControl() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Status Display */}
|
||||
<div className="border rounded-lg p-4 bg-muted/50">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Current Status:</span>
|
||||
<Badge variant={settingsKey?.status === 'active' ? 'default' : 'secondary'}>
|
||||
{settingsKey?.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{settingsKey?.expiration && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Current Expiration:</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDate(settingsKey.expiration)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSettingsDialogOpen(false)}>
|
||||
@@ -768,6 +971,190 @@ export function AccessControl() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Key Details Dialog */}
|
||||
<Dialog open={keyDetailsDialogOpen} onOpenChange={setKeyDetailsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
View and manage your API key credentials and permissions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Key Name and Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<div className="text-sm text-muted-foreground">{viewingKey?.name}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Status</label>
|
||||
<div>
|
||||
<Badge variant={viewingKey?.status === 'active' ? 'default' : 'secondary'}>
|
||||
{viewingKey?.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Key ID */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Access Key ID</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (viewingKey?.accessKeyId) {
|
||||
navigator.clipboard.writeText(viewingKey.accessKeyId);
|
||||
setCopiedAccessKeyId(true);
|
||||
setTimeout(() => setCopiedAccessKeyId(false), 2000);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{viewingKey?.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (viewingKey?.accessKeyId) {
|
||||
navigator.clipboard.writeText(viewingKey.accessKeyId);
|
||||
setCopiedAccessKeyId(true);
|
||||
setTimeout(() => setCopiedAccessKeyId(false), 2000);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copiedAccessKeyId ? 'Copied' : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secret Access Key */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Secret Access Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoadingDetailsSecretKey ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground flex-1 bg-muted px-3 py-2 rounded">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Loading secret key...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (detailsSecretKey) {
|
||||
navigator.clipboard.writeText(detailsSecretKey);
|
||||
setCopiedSecretKey(true);
|
||||
setTimeout(() => setCopiedSecretKey(false), 2000);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{'•'.repeat(40)}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (detailsSecretKey) {
|
||||
navigator.clipboard.writeText(detailsSecretKey);
|
||||
setCopiedSecretKey(true);
|
||||
setTimeout(() => setCopiedSecretKey(false), 2000);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
disabled={!detailsSecretKey}
|
||||
>
|
||||
{copiedSecretKey ? 'Copied' : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Created</label>
|
||||
<div className="text-sm text-muted-foreground">{viewingKey && formatDate(viewingKey.createdAt)}</div>
|
||||
</div>
|
||||
{viewingKey?.expiration && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Expiration</label>
|
||||
<div className="text-sm text-muted-foreground">{formatDate(viewingKey.expiration)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bucket Permissions */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Bucket Permissions</label>
|
||||
{viewingKey && viewingKey.permissions.length > 0 ? (
|
||||
<div className="border rounded-lg divide-y">
|
||||
{viewingKey.permissions.map((perm, idx) => (
|
||||
<div key={idx} className="p-3 flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{perm.bucketName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatPermissions(perm)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{perm.read && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Read
|
||||
</Badge>
|
||||
)}
|
||||
{perm.write && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Write
|
||||
</Badge>
|
||||
)}
|
||||
{perm.owner && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Owner
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This key has no bucket permissions yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setKeyDetailsDialogOpen(false);
|
||||
if (viewingKey) {
|
||||
handleOpenEditPermissions(viewingKey);
|
||||
}
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit Permissions
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setKeyDetailsDialogOpen(false)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Permissions Dialog */}
|
||||
<Dialog open={editPermissionsDialogOpen} onOpenChange={setEditPermissionsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { useBuckets } from '@/hooks/useBuckets';
|
||||
import { useBuckets, useCreateBucket, useDeleteBucket, useGrantBucketPermission } from '@/hooks/useApi';
|
||||
import { useBucketObjects } from '@/hooks/useBucketObjects';
|
||||
import { BucketListView } from '@/components/buckets/BucketListView';
|
||||
import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
|
||||
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
|
||||
import { DeleteBucketDialog } from '@/components/buckets/DeleteBucketDialog';
|
||||
import { BucketSettingsDialog } from '@/components/buckets/BucketSettingsDialog';
|
||||
import { BucketWebsiteDialog } from '@/components/buckets/BucketWebsiteDialog';
|
||||
import type { Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
import { bucketsApi } from '@/lib/api';
|
||||
|
||||
export function Buckets() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -21,6 +24,8 @@ export function Buckets() {
|
||||
const [selectedBucket, setSelectedBucket] = useState<Bucket | null>(null);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [settingsBucket, setSettingsBucket] = useState<Bucket | null>(null);
|
||||
const [websiteDialogOpen, setWebsiteDialogOpen] = useState(false);
|
||||
const [websiteBucket, setWebsiteBucket] = useState<Bucket | null>(null);
|
||||
|
||||
// Object browser state - initialize from URL params
|
||||
const [viewingBucket, setViewingBucket] = useState<string | null>(searchParams.get('bucket'));
|
||||
@@ -51,7 +56,11 @@ export function Buckets() {
|
||||
}, [searchParams]);
|
||||
|
||||
// Custom hooks
|
||||
const { buckets, isLoading: bucketsLoading, createBucket, deleteBucket, grantPermission } = useBuckets();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: buckets = [], isLoading: bucketsLoading } = useBuckets();
|
||||
const createBucketMutation = useCreateBucket();
|
||||
const deleteBucketMutation = useDeleteBucket();
|
||||
const grantPermissionMutation = useGrantBucketPermission();
|
||||
const {
|
||||
objects,
|
||||
isLoading: objectsLoading,
|
||||
@@ -62,6 +71,7 @@ export function Buckets() {
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
uploadFiles,
|
||||
uploadTasks,
|
||||
deleteObject,
|
||||
deleteMultipleObjects,
|
||||
createDirectory,
|
||||
@@ -135,6 +145,25 @@ export function Buckets() {
|
||||
setSettingsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenWebsiteSettings = (bucket: Bucket) => {
|
||||
setWebsiteBucket(bucket);
|
||||
setWebsiteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveWebsite = async (
|
||||
bucketName: string,
|
||||
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await bucketsApi.updateBucketWebsite(bucketName, payload);
|
||||
queryClient.invalidateQueries({ queryKey: ['buckets'] });
|
||||
toast.success('Website configuration updated');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshObjects = async () => {
|
||||
if (isRefreshing) return;
|
||||
try {
|
||||
@@ -145,6 +174,38 @@ export function Buckets() {
|
||||
}
|
||||
};
|
||||
|
||||
// Wrapper functions for mutations to match dialog APIs
|
||||
const createBucket = async (name: string, region?: string) => {
|
||||
try {
|
||||
await createBucketMutation.mutateAsync({ name, region });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBucket = async (name: string) => {
|
||||
try {
|
||||
await deleteBucketMutation.mutateAsync(name);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const grantPermission = async (
|
||||
bucketName: string,
|
||||
accessKeyId: string,
|
||||
permissions: { read: boolean; write: boolean; owner: boolean }
|
||||
) => {
|
||||
try {
|
||||
await grantPermissionMutation.mutateAsync({ bucketName, accessKeyId, permissions });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// If viewing a bucket's objects, show the object browser view
|
||||
if (viewingBucket) {
|
||||
return (
|
||||
@@ -161,6 +222,7 @@ export function Buckets() {
|
||||
onNavigateToFolder={handleNavigateToFolder}
|
||||
onBackToBuckets={handleBackToBuckets}
|
||||
onUploadFiles={uploadFiles}
|
||||
uploadTasks={uploadTasks}
|
||||
onDeleteObject={deleteObject}
|
||||
onDeleteMultipleObjects={deleteMultipleObjects}
|
||||
onCreateDirectory={createDirectory}
|
||||
@@ -187,6 +249,7 @@ export function Buckets() {
|
||||
onSearchChange={setSearchQuery}
|
||||
onViewBucket={handleViewBucket}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onWebsiteSettings={handleOpenWebsiteSettings}
|
||||
onCreateBucket={() => setCreateDialogOpen(true)}
|
||||
onDeleteBucket={(bucket) => {
|
||||
setSelectedBucket(bucket);
|
||||
@@ -215,6 +278,13 @@ export function Buckets() {
|
||||
bucket={settingsBucket}
|
||||
onGrantPermission={grantPermission}
|
||||
/>
|
||||
|
||||
<BucketWebsiteDialog
|
||||
open={websiteDialogOpen}
|
||||
onOpenChange={setWebsiteDialogOpen}
|
||||
bucket={websiteBucket}
|
||||
onSave={handleSaveWebsite}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {formatBytes} from '@/lib/file-utils';
|
||||
import {Activity, AlertCircle, CheckCircle2, Clock, Cpu, Database, Info, Network, Server, XCircle,} from 'lucide-react';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {garageApi} from '@/lib/api';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {formatBytes} from '@/lib/file-utils';
|
||||
import {AlertCircle, Database, FolderOpen, HardDrive, Server, Zap} from 'lucide-react';
|
||||
import {BucketUsageChart} from '@/components/charts/BucketUsageChart';
|
||||
import {useDashboardData} from '@/hooks/useApi';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { BasicLoginForm } from '@/components/auth/BasicLoginForm';
|
||||
import { OIDCLoginView } from '@/components/auth/OIDCLoginView';
|
||||
import { LoadingSpinner } from '@/components/auth/LoadingSpinner';
|
||||
|
||||
export function Login() {
|
||||
const { config, isLoading, initialize, isAuthenticated } = useAuthStore();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const loginSuccess = searchParams.get('login');
|
||||
const returnUrl = searchParams.get('returnUrl') || '/';
|
||||
|
||||
useEffect(() => {
|
||||
// Handle OIDC callback
|
||||
if (loginSuccess === 'success') {
|
||||
// OIDC login successful, re-initialize auth to fetch user
|
||||
initialize().then(() => {
|
||||
navigate(decodeURIComponent(returnUrl));
|
||||
});
|
||||
}
|
||||
}, [loginSuccess, initialize, navigate, returnUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
// If already authenticated, redirect to return URL
|
||||
if (isAuthenticated && !loginSuccess) {
|
||||
navigate(decodeURIComponent(returnUrl));
|
||||
}
|
||||
}, [isAuthenticated, navigate, returnUrl, loginSuccess]);
|
||||
|
||||
if (isLoading || loginSuccess === 'success') {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
// No auth enabled, redirect to dashboard immediately
|
||||
if (config && !config.admin.enabled && !config.oidc.enabled) {
|
||||
navigate('/');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show login options based on what's enabled
|
||||
const showAdmin = config?.admin.enabled || false;
|
||||
const showOIDC = config?.oidc.enabled || false;
|
||||
|
||||
// If both are enabled, show both options in single modal
|
||||
if (showAdmin && showOIDC) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<BasicLoginForm showOIDC={true} config={config} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show only OIDC if enabled
|
||||
if (showOIDC) {
|
||||
return <OIDCLoginView />;
|
||||
}
|
||||
|
||||
// Show only admin if enabled
|
||||
if (showAdmin) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<BasicLoginForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Still loading config
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { AuthConfig, AuthUser, AuthState } from '@/types/auth';
|
||||
import { authApi } from '@/lib/api';
|
||||
|
||||
interface AuthStore extends AuthState {
|
||||
config: AuthConfig | null;
|
||||
|
||||
// Actions
|
||||
setUser: (user: AuthUser | null) => void;
|
||||
setConfig: (config: AuthConfig) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setAuthenticated: (authenticated: boolean) => void;
|
||||
|
||||
// Async actions
|
||||
initialize: () => Promise<void>;
|
||||
loginAdmin: (username: string, password: string) => Promise<void>;
|
||||
loginOIDC: () => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
config: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
|
||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||
setConfig: (config) => set({ config }),
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
setError: (error) => set({ error }),
|
||||
setAuthenticated: (isAuthenticated) => set({ isAuthenticated }),
|
||||
|
||||
initialize: async () => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
// Fetch auth configuration
|
||||
const configResponse = await authApi.getConfig();
|
||||
const config = configResponse.data as AuthConfig;
|
||||
set({ config });
|
||||
|
||||
// If no auth is enabled, mark as authenticated immediately
|
||||
if (!config.admin.enabled && !config.oidc.enabled) {
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: { username: 'guest' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get current user (check if already authenticated)
|
||||
try {
|
||||
const userResponse = await authApi.me();
|
||||
const user = userResponse.data.user;
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
// Not authenticated - this is okay
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
set({
|
||||
error: 'Failed to initialize authentication',
|
||||
isLoading: false,
|
||||
isAuthenticated: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loginAdmin: async (username, password) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
const response = await authApi.loginAdmin(username, password);
|
||||
const { token, user } = response.data;
|
||||
|
||||
// Store token in localStorage
|
||||
localStorage.setItem('auth-token', token);
|
||||
|
||||
// Update state
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.error?.message || 'Login failed';
|
||||
set({
|
||||
error: errorMessage,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
user: null
|
||||
});
|
||||
throw error; // Re-throw for form handling
|
||||
}
|
||||
},
|
||||
|
||||
loginOIDC: () => {
|
||||
// Redirect to OIDC login endpoint
|
||||
window.location.href = '/auth/oidc/login';
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const { config } = get();
|
||||
|
||||
try {
|
||||
// Call logout endpoint for OIDC mode
|
||||
if (config?.oidc.enabled) {
|
||||
await authApi.logoutOIDC();
|
||||
} else if (config?.admin.enabled) {
|
||||
await authApi.logoutAdmin();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout API call failed:', error);
|
||||
}
|
||||
|
||||
// Clear local storage
|
||||
localStorage.removeItem('auth-token');
|
||||
|
||||
// Clear state
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
// Redirect to login page
|
||||
window.location.href = '/login';
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
// Don't persist config, isLoading, or error
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface AuthConfig {
|
||||
admin: {
|
||||
enabled: boolean;
|
||||
};
|
||||
oidc: {
|
||||
enabled: boolean;
|
||||
provider?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -5,6 +5,11 @@ export interface Bucket {
|
||||
objectCount?: number;
|
||||
size?: number;
|
||||
region?: string;
|
||||
websiteAccess: boolean;
|
||||
websiteConfig?: {
|
||||
indexDocument: string;
|
||||
errorDocument?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BucketDetails extends Bucket {
|
||||
@@ -33,6 +38,7 @@ export interface S3Object {
|
||||
size: number;
|
||||
lastModified: string;
|
||||
etag?: string;
|
||||
contentType?: string;
|
||||
storageClass?: string;
|
||||
isFolder?: boolean;
|
||||
}
|
||||
@@ -52,6 +58,7 @@ export interface ObjectMetadata {
|
||||
lastModified: string;
|
||||
contentType: string;
|
||||
etag: string;
|
||||
storageClass?: string;
|
||||
metadata?: Record<string, string>;
|
||||
versionId?: string;
|
||||
}
|
||||
@@ -60,8 +67,8 @@ export interface ObjectMetadata {
|
||||
export interface AccessKey {
|
||||
accessKeyId: string;
|
||||
name: string;
|
||||
secretKey?: string;
|
||||
createdAt: string;
|
||||
lastUsed?: string;
|
||||
status: 'active' | 'inactive';
|
||||
permissions: BucketPermission[];
|
||||
expiration?: string;
|
||||
|
||||
@@ -47,6 +47,15 @@ export default {
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
shimmer: {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(100%)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
shimmer: 'shimmer 2s infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
@@ -18,6 +18,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/auth': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: v2
|
||||
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.0
|
||||
appVersion: "v0.2.0"
|
||||
keywords:
|
||||
- garage
|
||||
- s3
|
||||
- object-storage
|
||||
- ui
|
||||
- dashboard
|
||||
- web-ui
|
||||
home: https://github.com/Noooste/garage-ui
|
||||
sources:
|
||||
- https://github.com/Noooste/garage-ui
|
||||
maintainers:
|
||||
- name: Noooste
|
||||
kubeVersion: ">=1.19.0-0"
|
||||
@@ -0,0 +1,982 @@
|
||||
# Garage UI Helm Chart
|
||||
|
||||
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)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Examples](#examples)
|
||||
- [Upgrading](#upgrading)
|
||||
- [Accessing the Application](#accessing-the-application)
|
||||
- [Monitoring](#monitoring)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Uninstalling](#uninstalling)
|
||||
|
||||
## Overview
|
||||
|
||||
Garage UI provides an intuitive web interface for managing your Garage S3 storage cluster, featuring:
|
||||
|
||||
- **Bucket Management** - Create, delete, and configure S3 buckets
|
||||
- **Object Operations** - Upload, download, and delete objects through the UI
|
||||
- **User Access Control** - Manage users, access keys, and permissions
|
||||
- **Cluster Monitoring** - View cluster health, status, and statistics
|
||||
- **Authentication** - Support for no auth, basic auth, and OIDC/SSO
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before installing this chart, ensure you have:
|
||||
|
||||
- **Kubernetes** `1.19+` or later
|
||||
- **Helm** `3.0+` or later
|
||||
- **Garage S3** instance running and accessible
|
||||
- **Garage Admin Token** - Required for administrative operations
|
||||
|
||||
### Required Information
|
||||
|
||||
You will need the following information from your Garage installation:
|
||||
|
||||
1. **Garage S3 Endpoint** - The Garage S3 API endpoint (default port: `3900`)
|
||||
2. **Garage Admin Endpoint** - The Garage Admin API endpoint (default port: `3903`)
|
||||
3. **Admin Token** - Bearer token for authenticating with the Admin API
|
||||
|
||||
To find your admin token, check your Garage server's configuration file.
|
||||
|
||||
## Quick Start
|
||||
|
||||
The fastest way to get started with Garage UI:
|
||||
|
||||
### Step 1: Create a values file
|
||||
|
||||
Create a file named `my-values.yaml` with your Garage configuration:
|
||||
|
||||
```yaml
|
||||
config:
|
||||
garage:
|
||||
endpoint: "http://garage:3900" # Your Garage S3 endpoint
|
||||
admin_endpoint: "http://garage:3903" # Your Garage Admin endpoint
|
||||
admin_token: "YOUR_ADMIN_TOKEN_HERE" # Your admin token
|
||||
region: "garage" # S3 region (can be any value)
|
||||
```
|
||||
|
||||
### Step 2: Install the chart
|
||||
|
||||
```bash
|
||||
helm install garage-ui ./helm/garage-ui -f my-values.yaml
|
||||
```
|
||||
|
||||
### Step 3: Access the UI
|
||||
|
||||
```bash
|
||||
# Forward port to access locally
|
||||
kubectl port-forward svc/garage-ui 8080:80
|
||||
|
||||
# Open in your browser
|
||||
open http://localhost:8080
|
||||
```
|
||||
|
||||
That's it! You should now have Garage UI running and accessible.
|
||||
|
||||
## Installation
|
||||
|
||||
### Installing from local chart
|
||||
|
||||
If you've cloned the repository:
|
||||
|
||||
```bash
|
||||
helm install garage-ui ./helm/garage-ui -f my-values.yaml
|
||||
```
|
||||
|
||||
### Installing with inline values
|
||||
|
||||
You can also set values directly on the command line:
|
||||
|
||||
```bash
|
||||
helm install garage-ui ./helm/garage-ui \
|
||||
--set config.garage.endpoint=http://garage:3900 \
|
||||
--set config.garage.admin_endpoint=http://garage:3903 \
|
||||
--set config.garage.admin_token=your-token-here
|
||||
```
|
||||
|
||||
### Verify installation
|
||||
|
||||
Check that the pod is running:
|
||||
|
||||
```bash
|
||||
kubectl get pods -l app.kubernetes.io/name=garage-ui
|
||||
```
|
||||
|
||||
View the logs:
|
||||
|
||||
```bash
|
||||
kubectl logs -l app.kubernetes.io/name=garage-ui
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Configuration Structure
|
||||
|
||||
The chart uses a structured `config` section that maps directly to the application's configuration file. You can override any value using Helm's standard methods.
|
||||
|
||||
### Minimal Configuration
|
||||
|
||||
The absolute minimum configuration requires only the Garage endpoints and admin token:
|
||||
|
||||
```yaml
|
||||
config:
|
||||
garage:
|
||||
endpoint: "http://garage:3900"
|
||||
admin_endpoint: "http://garage:3903"
|
||||
admin_token: "your-admin-token"
|
||||
```
|
||||
|
||||
### Common Configuration Options
|
||||
|
||||
#### Server Settings
|
||||
|
||||
```yaml
|
||||
config:
|
||||
server:
|
||||
port: 8080 # Application port
|
||||
environment: production # Environment (production/development/staging)
|
||||
```
|
||||
|
||||
#### Authentication Configuration
|
||||
|
||||
**No Authentication** (default, suitable for private networks):
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
admin:
|
||||
enabled: false
|
||||
oidc:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
**Admin Authentication** (username/password with JWT):
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
admin:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password: "your-secure-password"
|
||||
oidc:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
**OIDC/SSO** (recommended for production):
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
admin:
|
||||
enabled: false
|
||||
oidc:
|
||||
enabled: true
|
||||
provider_name: "Keycloak"
|
||||
client_id: "garage-ui"
|
||||
client_secret: "your-oidc-secret"
|
||||
issuer_url: "https://auth.example.com/realms/master"
|
||||
# ... additional OIDC settings
|
||||
```
|
||||
|
||||
**Both Authentication Methods** (admin and OIDC simultaneously):
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
admin:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password: "your-secure-password"
|
||||
oidc:
|
||||
enabled: true
|
||||
provider_name: "Keycloak"
|
||||
client_id: "garage-ui"
|
||||
client_secret: "your-oidc-secret"
|
||||
issuer_url: "https://auth.example.com/realms/master"
|
||||
# ... additional OIDC settings
|
||||
```
|
||||
|
||||
#### CORS Configuration
|
||||
|
||||
```yaml
|
||||
config:
|
||||
cors:
|
||||
enabled: true
|
||||
allowed_origins:
|
||||
- "https://garage-ui.example.com" # Recommended: specific origins
|
||||
# - "*" # Or: allow all origins (less secure)
|
||||
```
|
||||
|
||||
#### Logging
|
||||
|
||||
```yaml
|
||||
config:
|
||||
logging:
|
||||
level: info # debug, info, warn, error
|
||||
format: json # json (recommended for production), text
|
||||
```
|
||||
|
||||
### Kubernetes Resource Configuration
|
||||
|
||||
#### Ingress
|
||||
|
||||
Enable external access with Ingress:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: garage-ui.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
```
|
||||
|
||||
With TLS:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
hosts:
|
||||
- host: garage-ui.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: garage-ui-tls
|
||||
hosts:
|
||||
- garage-ui.example.com
|
||||
```
|
||||
|
||||
#### Resource Limits
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
```
|
||||
|
||||
#### Replicas and High Availability
|
||||
|
||||
```yaml
|
||||
replicaCount: 3
|
||||
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/name
|
||||
operator: In
|
||||
values:
|
||||
- garage-ui
|
||||
topologyKey: kubernetes.io/hostname
|
||||
```
|
||||
|
||||
### Complete Parameters Reference
|
||||
|
||||
For a complete list of all available parameters, see the [values.yaml](values.yaml) file which includes detailed comments for every configuration option.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Basic Installation (In-Cluster Garage)
|
||||
|
||||
For Garage running in the same Kubernetes cluster:
|
||||
|
||||
```yaml
|
||||
# values-basic.yaml
|
||||
config:
|
||||
garage:
|
||||
endpoint: "http://garage.garage-system.svc.cluster.local:3900"
|
||||
admin_endpoint: "http://garage.garage-system.svc.cluster.local:3903"
|
||||
admin_token: "GgJLd9J...your-token-here"
|
||||
region: "garage"
|
||||
```
|
||||
|
||||
Install:
|
||||
```bash
|
||||
helm install garage-ui ./helm/garage-ui -f values-basic.yaml
|
||||
```
|
||||
|
||||
### Example 2: External Access with Ingress
|
||||
|
||||
```yaml
|
||||
# values-ingress.yaml
|
||||
config:
|
||||
garage:
|
||||
endpoint: "http://garage:3900"
|
||||
admin_endpoint: "http://garage:3903"
|
||||
admin_token: "your-admin-token"
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
hosts:
|
||||
- host: garage-ui.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: garage-ui-tls
|
||||
hosts:
|
||||
- garage-ui.example.com
|
||||
```
|
||||
|
||||
Install:
|
||||
```bash
|
||||
helm install garage-ui ./helm/garage-ui -f values-ingress.yaml
|
||||
```
|
||||
|
||||
### Example 3: With Admin Authentication
|
||||
|
||||
```yaml
|
||||
# values-admin-auth.yaml
|
||||
config:
|
||||
garage:
|
||||
endpoint: "http://garage:3900"
|
||||
admin_endpoint: "http://garage:3903"
|
||||
admin_token: "your-admin-token"
|
||||
|
||||
auth:
|
||||
admin:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password: "super-secret-password-change-me"
|
||||
oidc:
|
||||
enabled: false
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: garage.local
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
```
|
||||
|
||||
Install:
|
||||
```bash
|
||||
helm install garage-ui ./helm/garage-ui -f values-admin-auth.yaml
|
||||
```
|
||||
|
||||
### Example 4: Production Setup with OIDC (Keycloak)
|
||||
|
||||
```yaml
|
||||
# values-production.yaml
|
||||
replicaCount: 3
|
||||
|
||||
config:
|
||||
server:
|
||||
environment: production
|
||||
|
||||
garage:
|
||||
endpoint: "https://s3.garage.example.com"
|
||||
admin_endpoint: "https://admin.garage.example.com"
|
||||
admin_token: "your-admin-token"
|
||||
region: "us-east-1"
|
||||
|
||||
auth:
|
||||
admin:
|
||||
enabled: false
|
||||
oidc:
|
||||
enabled: true
|
||||
provider_name: "Keycloak"
|
||||
client_id: "garage-ui"
|
||||
client_secret: "your-oidc-client-secret"
|
||||
scopes:
|
||||
- openid
|
||||
- email
|
||||
- profile
|
||||
issuer_url: "https://auth.example.com/realms/production"
|
||||
auth_url: "https://auth.example.com/realms/production/protocol/openid-connect/auth"
|
||||
token_url: "https://auth.example.com/realms/production/protocol/openid-connect/token"
|
||||
userinfo_url: "https://auth.example.com/realms/production/protocol/openid-connect/userinfo"
|
||||
cookie_secure: true
|
||||
cookie_http_only: true
|
||||
cookie_same_site: "lax"
|
||||
|
||||
cors:
|
||||
enabled: true
|
||||
allowed_origins:
|
||||
- "https://garage-ui.example.com"
|
||||
|
||||
logging:
|
||||
level: info
|
||||
format: json
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
|
||||
hosts:
|
||||
- host: garage-ui.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: garage-ui-tls
|
||||
hosts:
|
||||
- garage-ui.example.com
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1Gi
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/name
|
||||
operator: In
|
||||
values:
|
||||
- garage-ui
|
||||
topologyKey: kubernetes.io/hostname
|
||||
```
|
||||
|
||||
Install:
|
||||
```bash
|
||||
helm install garage-ui ./helm/garage-ui -f values-production.yaml
|
||||
```
|
||||
|
||||
### Example 5: With Prometheus Monitoring
|
||||
|
||||
```yaml
|
||||
# values-monitoring.yaml
|
||||
config:
|
||||
garage:
|
||||
endpoint: "http://garage:3900"
|
||||
admin_endpoint: "http://garage:3903"
|
||||
admin_token: "your-admin-token"
|
||||
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
interval: 30s
|
||||
labels:
|
||||
prometheus: kube-prometheus
|
||||
```
|
||||
|
||||
Install:
|
||||
```bash
|
||||
helm install garage-ui ./helm/garage-ui -f values-monitoring.yaml
|
||||
```
|
||||
|
||||
### Example 6: Using Kubernetes Secrets for Admin Token (Recommended)
|
||||
|
||||
For improved security, store the admin token in a Kubernetes secret instead of in values files:
|
||||
|
||||
**Option A: Use an existing secret**
|
||||
|
||||
First, create a Kubernetes secret:
|
||||
```bash
|
||||
kubectl create secret generic garage-admin-token \
|
||||
--from-literal=admin-token='your-admin-token-here'
|
||||
```
|
||||
|
||||
Then configure the chart to use it:
|
||||
```yaml
|
||||
# values-with-secret.yaml
|
||||
config:
|
||||
garage:
|
||||
endpoint: "http://garage:3900"
|
||||
admin_endpoint: "http://garage:3903"
|
||||
# Leave admin_token empty when using existingSecret
|
||||
admin_token: ""
|
||||
|
||||
# Reference the existing secret
|
||||
existingSecret:
|
||||
name: "garage-admin-token"
|
||||
key: "admin-token"
|
||||
```
|
||||
|
||||
Install:
|
||||
```bash
|
||||
helm install garage-ui ./helm/garage-ui -f values-with-secret.yaml
|
||||
```
|
||||
|
||||
**Option B: Let the chart create the secret**
|
||||
|
||||
If you provide `admin_token` in values but don't configure `existingSecret`, the chart will automatically create a secret for you:
|
||||
|
||||
```yaml
|
||||
# values-auto-secret.yaml
|
||||
config:
|
||||
garage:
|
||||
endpoint: "http://garage:3900"
|
||||
admin_endpoint: "http://garage:3903"
|
||||
# Chart will create a secret with this value
|
||||
admin_token: "your-admin-token"
|
||||
```
|
||||
|
||||
This approach keeps the token out of the ConfigMap while still allowing you to manage it through Helm values.
|
||||
|
||||
## Upgrading
|
||||
|
||||
### Upgrade to a new version
|
||||
|
||||
```bash
|
||||
helm upgrade garage-ui ./helm/garage-ui -f my-values.yaml
|
||||
```
|
||||
|
||||
### Upgrade with new values
|
||||
|
||||
```bash
|
||||
helm upgrade garage-ui ./helm/garage-ui \
|
||||
--set config.logging.level=debug \
|
||||
--reuse-values
|
||||
```
|
||||
|
||||
### Check upgrade history
|
||||
|
||||
```bash
|
||||
helm history garage-ui
|
||||
```
|
||||
|
||||
### Rollback to previous version
|
||||
|
||||
```bash
|
||||
helm rollback garage-ui
|
||||
```
|
||||
|
||||
## Accessing the Application
|
||||
|
||||
### Method 1: Port Forward (Development)
|
||||
|
||||
Quick access for testing:
|
||||
|
||||
```bash
|
||||
kubectl port-forward svc/garage-ui 8080:80
|
||||
```
|
||||
|
||||
Then open: http://localhost:8080
|
||||
|
||||
### Method 2: Ingress (Production)
|
||||
|
||||
If you've enabled Ingress with a domain:
|
||||
|
||||
```bash
|
||||
# Access via your configured domain
|
||||
open https://garage-ui.example.com
|
||||
```
|
||||
|
||||
### Method 3: NodePort (Alternative)
|
||||
|
||||
Change service type to NodePort:
|
||||
|
||||
```yaml
|
||||
service:
|
||||
type: NodePort
|
||||
port: 80
|
||||
```
|
||||
|
||||
Find the assigned port:
|
||||
|
||||
```bash
|
||||
kubectl get svc garage-ui
|
||||
```
|
||||
|
||||
### Method 4: LoadBalancer (Cloud Environments)
|
||||
|
||||
For cloud providers with LoadBalancer support:
|
||||
|
||||
```yaml
|
||||
service:
|
||||
type: LoadBalancer
|
||||
port: 80
|
||||
```
|
||||
|
||||
Get the external IP:
|
||||
|
||||
```bash
|
||||
kubectl get svc garage-ui
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Prometheus ServiceMonitor
|
||||
|
||||
Enable Prometheus metrics scraping (requires Prometheus Operator):
|
||||
|
||||
```yaml
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
interval: 30s
|
||||
path: /api/v1/monitoring/metrics
|
||||
labels:
|
||||
prometheus: kube-prometheus
|
||||
```
|
||||
|
||||
### Metrics Endpoint
|
||||
|
||||
The application exposes metrics at:
|
||||
- Path: `/api/v1/monitoring/metrics`
|
||||
- Format: Prometheus format (proxies Garage Admin API metrics)
|
||||
|
||||
### Health Checks
|
||||
|
||||
Health endpoint available at:
|
||||
- Path: `/health`
|
||||
- Response: `{"status": "ok", "version": "0.1.0"}`
|
||||
|
||||
Used by Kubernetes liveness and readiness probes.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pods Not Starting
|
||||
|
||||
Check pod status and logs:
|
||||
|
||||
```bash
|
||||
kubectl get pods -l app.kubernetes.io/name=garage-ui
|
||||
kubectl describe pod -l app.kubernetes.io/name=garage-ui
|
||||
kubectl logs -l app.kubernetes.io/name=garage-ui
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- **Missing admin token**: Ensure `config.garage.admin_token` is set or `config.garage.existingSecret.name` points to a valid secret
|
||||
- **Secret not found**: If using `existingSecret`, verify the secret exists: `kubectl get secret <secret-name>`
|
||||
- **Unreachable Garage**: Verify endpoints are accessible from within the cluster
|
||||
- **Invalid OIDC config**: Check all OIDC URLs and credentials when using `auth.oidc.enabled: true`
|
||||
|
||||
### Cannot Access the UI
|
||||
|
||||
For Ingress issues:
|
||||
|
||||
```bash
|
||||
kubectl get ingress
|
||||
kubectl describe ingress garage-ui
|
||||
```
|
||||
|
||||
Check Ingress controller logs:
|
||||
|
||||
```bash
|
||||
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- **DNS not configured**: Ensure your domain points to the Ingress controller
|
||||
- **Certificate issues**: Check cert-manager logs if using automatic TLS
|
||||
- **Ingress class mismatch**: Verify `ingress.className` matches your controller
|
||||
|
||||
### Configuration Not Updating
|
||||
|
||||
The deployment includes a ConfigMap checksum annotation that automatically triggers pod restarts when configuration changes. If it's not working:
|
||||
|
||||
```bash
|
||||
kubectl rollout restart deployment/garage-ui
|
||||
```
|
||||
|
||||
### Connection to Garage Fails
|
||||
|
||||
Test connectivity from within the pod:
|
||||
|
||||
```bash
|
||||
kubectl exec -it deployment/garage-ui -- sh
|
||||
|
||||
# Inside the pod:
|
||||
curl http://garage:3900
|
||||
curl http://garage:3903/health
|
||||
```
|
||||
|
||||
### Authentication Issues
|
||||
|
||||
**Admin Auth:**
|
||||
- Verify username/password in `config.auth.admin`
|
||||
- Ensure `config.auth.admin.enabled` is set to `true`
|
||||
- Check browser developer tools for 401 errors
|
||||
- Verify JWT token is being sent in Authorization header
|
||||
|
||||
**OIDC:**
|
||||
- Ensure `config.auth.oidc.enabled` is set to `true`
|
||||
- Verify all OIDC URLs are accessible
|
||||
- Check OIDC provider logs
|
||||
- Ensure redirect URI is registered: `https://your-domain/auth/oidc/callback`
|
||||
- Verify client ID and secret
|
||||
|
||||
### View Application Logs
|
||||
|
||||
```bash
|
||||
# Follow logs
|
||||
kubectl logs -f -l app.kubernetes.io/name=garage-ui
|
||||
|
||||
# View recent logs
|
||||
kubectl logs --tail=100 -l app.kubernetes.io/name=garage-ui
|
||||
|
||||
# Export logs
|
||||
kubectl logs -l app.kubernetes.io/name=garage-ui > garage-ui.log
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging:
|
||||
|
||||
```yaml
|
||||
config:
|
||||
logging:
|
||||
level: debug
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
### Remove the release
|
||||
|
||||
```bash
|
||||
helm uninstall garage-ui
|
||||
```
|
||||
|
||||
This removes all Kubernetes components associated with the chart.
|
||||
|
||||
### Clean up completely
|
||||
|
||||
If you want to remove all associated resources:
|
||||
|
||||
```bash
|
||||
# Uninstall the release
|
||||
helm uninstall garage-ui
|
||||
|
||||
# Remove any remaining ConfigMaps
|
||||
kubectl delete configmap -l app.kubernetes.io/name=garage-ui
|
||||
|
||||
# Remove any remaining Secrets
|
||||
kubectl delete secret -l app.kubernetes.io/name=garage-ui
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Using Secrets for Sensitive Data
|
||||
|
||||
The chart supports using Kubernetes secrets for sensitive data to avoid storing credentials in values files.
|
||||
|
||||
#### Admin Token from Secret
|
||||
|
||||
The chart has built-in support for storing the admin token in a Kubernetes secret:
|
||||
|
||||
**Method 1: Use an existing secret** (recommended)
|
||||
|
||||
```bash
|
||||
# Create the secret
|
||||
kubectl create secret generic garage-admin-token \
|
||||
--from-literal=admin-token='your-admin-token-here'
|
||||
```
|
||||
|
||||
Configure in values:
|
||||
```yaml
|
||||
config:
|
||||
garage:
|
||||
existingSecret:
|
||||
name: "garage-admin-token"
|
||||
key: "admin-token"
|
||||
```
|
||||
|
||||
**Method 2: Let the chart create the secret**
|
||||
|
||||
Simply provide the `admin_token` in values, and the chart will automatically create a secret:
|
||||
|
||||
```yaml
|
||||
config:
|
||||
garage:
|
||||
admin_token: "your-admin-token"
|
||||
```
|
||||
|
||||
The chart will:
|
||||
1. Create a secret named `<release-name>-admin-token` with the token
|
||||
2. Remove the token from the ConfigMap
|
||||
3. Inject it into the pod via environment variable
|
||||
|
||||
This keeps sensitive data out of ConfigMaps while maintaining easy Helm-based management.
|
||||
|
||||
#### JWT Private Key for Session Tokens
|
||||
|
||||
The chart automatically manages JWT private keys for signing session tokens. You have three options:
|
||||
|
||||
**Method 1: Auto-generation** (recommended for most cases)
|
||||
|
||||
By default, if you don't provide a JWT private key, the chart will automatically generate an Ed25519 private key on first install and persist it in a Kubernetes secret. This key will be reused across upgrades, ensuring session tokens remain valid.
|
||||
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
# Leave both empty - chart will auto-generate and persist a key
|
||||
jwt_private_key: ""
|
||||
jwt_private_key_secret:
|
||||
name: ""
|
||||
```
|
||||
|
||||
The chart will:
|
||||
1. Run a pre-install/pre-upgrade Job to generate an Ed25519 key
|
||||
2. Store it in a secret named `<release-name>-jwt-key`
|
||||
3. Preserve the secret across chart upgrades (using `helm.sh/resource-policy: keep`)
|
||||
|
||||
**Method 2: Provide your own key inline**
|
||||
|
||||
Generate a key manually and include it in values:
|
||||
|
||||
```bash
|
||||
# Generate Ed25519 private key
|
||||
openssl genpkey -algorithm ED25519 -out jwt-key.pem
|
||||
```
|
||||
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
jwt_private_key: |
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEI...
|
||||
-----END PRIVATE KEY-----
|
||||
jwt_private_key_secret:
|
||||
name: ""
|
||||
```
|
||||
|
||||
**Method 3: Use an existing Kubernetes secret**
|
||||
|
||||
Create the secret manually:
|
||||
|
||||
```bash
|
||||
# Generate key
|
||||
openssl genpkey -algorithm ED25519 -out jwt-key.pem
|
||||
|
||||
# Create secret
|
||||
kubectl create secret generic my-jwt-key \
|
||||
--from-file=jwt-key.pem=jwt-key.pem
|
||||
```
|
||||
|
||||
Configure in values:
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
jwt_private_key: ""
|
||||
jwt_private_key_secret:
|
||||
name: "my-jwt-key"
|
||||
key: "jwt-key.pem"
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Ed25519 keys are recommended over RSA for better performance and security
|
||||
- The auto-generated key persists across upgrades, so tokens remain valid
|
||||
- For multi-replica deployments, all pods share the same key from the secret
|
||||
- The secret is marked with `helm.sh/resource-policy: keep` to prevent deletion during uninstall
|
||||
|
||||
#### OIDC Client Secret
|
||||
|
||||
For OIDC credentials, you can similarly use secrets:
|
||||
|
||||
**Method 1: Let the chart create the secret**
|
||||
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
oidc:
|
||||
enabled: true
|
||||
client_secret: "your-oidc-secret"
|
||||
# Chart will create a secret automatically
|
||||
```
|
||||
|
||||
**Method 2: Use an existing secret**
|
||||
|
||||
```bash
|
||||
kubectl create secret generic garage-oidc \
|
||||
--from-literal=client-secret='your-oidc-secret'
|
||||
```
|
||||
|
||||
```yaml
|
||||
config:
|
||||
auth:
|
||||
oidc:
|
||||
enabled: true
|
||||
client_secret: "" # Leave empty when using existingSecret
|
||||
existingSecret:
|
||||
name: "garage-oidc"
|
||||
key: "client-secret"
|
||||
```
|
||||
|
||||
### Network Policies
|
||||
|
||||
Enable network policies for enhanced security:
|
||||
|
||||
```yaml
|
||||
networkPolicy:
|
||||
enabled: true
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
```
|
||||
|
||||
This restricts network traffic to/from the pods. Requires a CNI that supports NetworkPolicy (e.g., Calico, Cilium).
|
||||
|
||||
### Custom Container Images
|
||||
|
||||
Use a custom or private registry:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
repository: myregistry.example.com/garage-ui
|
||||
tag: "v1.0.0"
|
||||
pullPolicy: Always
|
||||
|
||||
imagePullSecrets:
|
||||
- name: myregistrykey
|
||||
```
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
| Chart Version | App Version | Kubernetes | Garage |
|
||||
|--------------|-------------|------------|--------|
|
||||
| 0.1.1 | v0.0.6 | 1.19+ | 0.8+ |
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **GitHub Repository**: https://github.com/Noooste/garage-ui
|
||||
- **Garage Documentation**: https://garagehq.deuxfleurs.fr/
|
||||
- **Issue Tracker**: https://github.com/Noooste/garage-ui/issues
|
||||
- **Helm Documentation**: https://helm.sh/docs/
|
||||
|
||||
## Support
|
||||
|
||||
For help and support:
|
||||
|
||||
1. **Documentation**: Check this README and the [values.yaml](values.yaml) file
|
||||
2. **GitHub Issues**: Report bugs or request features at https://github.com/Noooste/garage-ui/issues
|
||||
3. **Garage Community**: Visit https://garagehq.deuxfleurs.fr/ for Garage-specific questions
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This Helm chart is open source and distributed under the same license as Garage UI.
|
||||
@@ -0,0 +1,27 @@
|
||||
1. Get the application URL by running these commands:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
{{- range .paths }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- else if contains "NodePort" .Values.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "garage-ui.fullname" . }})
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo http://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "garage-ui.fullname" . }}'
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "garage-ui.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "garage-ui.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||
echo "Visit http://127.0.0.1:8080 to use your application"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||
{{- end }}
|
||||
|
||||
2. To view logs:
|
||||
kubectl logs -f deployment/{{ include "garage-ui.fullname" . }} -n {{ .Release.Namespace }}
|
||||
|
||||
For more information, see the README at https://github.com/Noooste/garage-ui/blob/main/helm/garage-ui/README.md
|
||||
@@ -0,0 +1,51 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "garage-ui.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "garage-ui.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "garage-ui.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "garage-ui.labels" -}}
|
||||
helm.sh/chart: {{ include "garage-ui.chart" . }}
|
||||
{{ include "garage-ui.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "garage-ui.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "garage-ui.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-config
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
data:
|
||||
config.yaml: |
|
||||
{{- $config := deepCopy .Values.config }}
|
||||
{{- $_ := unset $config.garage "admin_token" }}
|
||||
{{- $_2 := unset $config.auth.admin "password" }}
|
||||
{{- $_3 := unset $config.auth.oidc "client_secret" }}
|
||||
{{- $_4 := unset $config.auth "jwt_private_key" }}
|
||||
{{- $_5 := unset $config.auth "jwt_private_key_secret" }}
|
||||
{{- $config | toYaml | nindent 4 }}
|
||||
@@ -0,0 +1,125 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "garage-ui.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "garage-ui.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.config.server.port }}
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: GARAGE_UI_GARAGE_ADMIN_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
{{- if .Values.config.garage.existingSecret.name }}
|
||||
name: {{ .Values.config.garage.existingSecret.name }}
|
||||
key: {{ .Values.config.garage.existingSecret.key }}
|
||||
{{- else }}
|
||||
name: {{ include "garage-ui.fullname" . }}-admin-token
|
||||
key: admin-token
|
||||
{{- end }}
|
||||
- name: GARAGE_UI_AUTH_JWT_PRIVATE_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
{{- if .Values.config.auth.jwt_private_key_secret.name }}
|
||||
name: {{ .Values.config.auth.jwt_private_key_secret.name }}
|
||||
key: {{ .Values.config.auth.jwt_private_key_secret.key }}
|
||||
{{- else }}
|
||||
name: {{ include "garage-ui.fullname" . }}-jwt-key
|
||||
key: jwt-key.pem
|
||||
{{- end }}
|
||||
{{- if .Values.config.auth.oidc.enabled }}
|
||||
- name: GARAGE_UI_AUTH_OIDC_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
{{- if .Values.config.auth.oidc.existingSecret.name }}
|
||||
name: {{ .Values.config.auth.oidc.existingSecret.name }}
|
||||
key: {{ .Values.config.auth.oidc.existingSecret.key }}
|
||||
{{- else }}
|
||||
name: {{ include "garage-ui.fullname" . }}-oidc-client-secret
|
||||
key: client-secret
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.config.auth.admin.enabled }}
|
||||
- name: GARAGE_UI_AUTH_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
{{- if .Values.config.auth.admin.existingSecret.name }}
|
||||
name: {{ .Values.config.auth.admin.existingSecret.name }}
|
||||
key: {{ .Values.config.auth.admin.existingSecret.key }}
|
||||
{{- else }}
|
||||
name: {{ include "garage-ui.fullname" . }}-admin-password
|
||||
key: admin-password
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.livenessProbe.enabled }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.livenessProbe.httpGet.path }}
|
||||
port: {{ .Values.livenessProbe.httpGet.port }}
|
||||
initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.livenessProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.livenessProbe.failureThreshold }}
|
||||
{{- end }}
|
||||
{{- if .Values.readinessProbe.enabled }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.readinessProbe.httpGet.path }}
|
||||
port: {{ .Values.readinessProbe.httpGet.port }}
|
||||
initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.readinessProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.readinessProbe.failureThreshold }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/config.yaml
|
||||
subPath: config.yaml
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: {{ include "garage-ui.fullname" . }}-config
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,41 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "garage-ui.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{- if .Values.networkPolicy.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
{{- include "garage-ui.selectorLabels" . | nindent 6 }}
|
||||
policyTypes:
|
||||
{{- toYaml .Values.networkPolicy.policyTypes | nindent 4 }}
|
||||
ingress:
|
||||
- from: []
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
egress:
|
||||
- to: []
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
{{- end }}
|
||||
@@ -0,0 +1,72 @@
|
||||
{{- if and (not .Values.config.garage.existingSecret.name) .Values.config.garage.admin_token }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-admin-token
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
admin-token: {{ .Values.config.garage.admin_token | b64enc | quote }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and .Values.config.auth.admin.enabled (not .Values.config.auth.admin.existingSecret.name) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-admin-password
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{- if .Values.config.auth.admin.password }}
|
||||
admin-password: {{ .Values.config.auth.admin.password | b64enc | quote }}
|
||||
{{- else }}
|
||||
admin-password: {{ randAlphaNum 32 | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and .Values.config.auth.oidc.enabled (not .Values.config.auth.oidc.existingSecret.name) .Values.config.auth.oidc.client_secret }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-oidc-client-secret
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
client-secret: {{ .Values.config.auth.oidc.client_secret | b64enc | quote }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and (not .Values.config.auth.jwt_private_key_secret.name) .Values.config.auth.jwt_private_key }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-jwt-key
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
"helm.sh/resource-policy": keep
|
||||
type: Opaque
|
||||
data:
|
||||
jwt-key.pem: {{ .Values.config.auth.jwt_private_key | b64enc | quote }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if not .Values.config.auth.jwt_private_key_secret.name }}
|
||||
{{- $secretName := printf "%s-jwt-key" (include "garage-ui.fullname" .) }}
|
||||
{{- $existingSecret := lookup "v1" "Secret" .Release.Namespace $secretName }}
|
||||
{{- if not $existingSecret }}
|
||||
{{- $privateKey := genPrivateKey "ed25519" }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ $secretName }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
"helm.sh/resource-policy": keep
|
||||
type: Opaque
|
||||
data:
|
||||
jwt-key.pem: {{ $privateKey | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "garage-ui.selectorLabels" . | nindent 4 }}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{- if .Values.serviceMonitor.enabled -}}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceMonitor.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "garage-ui.selectorLabels" . | nindent 6 }}
|
||||
endpoints:
|
||||
- port: http
|
||||
path: {{ .Values.serviceMonitor.path }}
|
||||
interval: {{ .Values.serviceMonitor.interval }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,854 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Garage UI Helm Chart Values",
|
||||
"description": "Configuration values for the Garage UI Helm chart deployment",
|
||||
"type": "object",
|
||||
"required": ["replicaCount", "image", "config"],
|
||||
"properties": {
|
||||
"replicaCount": {
|
||||
"type": "integer",
|
||||
"description": "Number of replica pods to run. Increase for high availability (recommended: 2-3 for production)",
|
||||
"minimum": 1,
|
||||
"default": 1
|
||||
},
|
||||
"image": {
|
||||
"type": "object",
|
||||
"description": "Docker image configuration",
|
||||
"required": ["repository", "pullPolicy"],
|
||||
"properties": {
|
||||
"repository": {
|
||||
"type": "string",
|
||||
"description": "Container registry and image name",
|
||||
"default": "noooste/garage-ui"
|
||||
},
|
||||
"pullPolicy": {
|
||||
"type": "string",
|
||||
"description": "Image pull policy",
|
||||
"enum": ["Always", "IfNotPresent", "Never"],
|
||||
"default": "IfNotPresent"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "Image tag to use (defaults to chart appVersion if empty)",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"imagePullSecrets": {
|
||||
"type": "array",
|
||||
"description": "Credentials for accessing private container registries",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"nameOverride": {
|
||||
"type": "string",
|
||||
"description": "Override the default chart name in resource names",
|
||||
"default": ""
|
||||
},
|
||||
"fullnameOverride": {
|
||||
"type": "string",
|
||||
"description": "Override the full resource name (includes release name)",
|
||||
"default": ""
|
||||
},
|
||||
"config": {
|
||||
"type": "object",
|
||||
"description": "Main application configuration",
|
||||
"required": ["server", "garage", "auth", "cors", "logging"],
|
||||
"properties": {
|
||||
"server": {
|
||||
"type": "object",
|
||||
"description": "Server configuration",
|
||||
"required": ["host", "port", "environment", "protocol"],
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Network interface to bind to (0.0.0.0 for all interfaces)",
|
||||
"default": "0.0.0.0"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"description": "Port the application listens on",
|
||||
"minimum": 1,
|
||||
"maximum": 65535,
|
||||
"default": 8080
|
||||
},
|
||||
"environment": {
|
||||
"type": "string",
|
||||
"description": "Deployment environment",
|
||||
"enum": ["production", "development", "staging"],
|
||||
"default": "production"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "Domain name for the application",
|
||||
"default": "garage-ui.example.com"
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"description": "Protocol for internal communication",
|
||||
"enum": ["http", "https"],
|
||||
"default": "http"
|
||||
},
|
||||
"root_url": {
|
||||
"type": "string",
|
||||
"description": "Full external URL for OAuth2 redirects (REQUIRED when OIDC is enabled)",
|
||||
"pattern": "^https?://",
|
||||
"default": "https://garage-ui.example.com"
|
||||
},
|
||||
"max_body_size": {
|
||||
"type": "integer",
|
||||
"description": "Maximum request body size in bytes (for file uploads)",
|
||||
"minimum": 1,
|
||||
"default": 314572800
|
||||
},
|
||||
"max_header_size": {
|
||||
"type": "integer",
|
||||
"description": "Maximum request header size in bytes",
|
||||
"minimum": 1,
|
||||
"default": 1048576
|
||||
},
|
||||
"read_buffer_size": {
|
||||
"type": "integer",
|
||||
"description": "Read buffer size for request data in bytes",
|
||||
"minimum": 1,
|
||||
"default": 4096
|
||||
},
|
||||
"write_buffer_size": {
|
||||
"type": "integer",
|
||||
"description": "Write buffer size for response data in bytes",
|
||||
"minimum": 1,
|
||||
"default": 4096
|
||||
}
|
||||
}
|
||||
},
|
||||
"garage": {
|
||||
"type": "object",
|
||||
"description": "Garage S3 storage configuration",
|
||||
"required": ["endpoint", "region", "admin_endpoint"],
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"description": "Garage S3 API endpoint",
|
||||
"pattern": "^(https?://)?[a-zA-Z0-9.-]+(:[0-9]+)?$",
|
||||
"default": "http://garage:3900"
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "S3 region name (can be any value for Garage)",
|
||||
"default": "garage"
|
||||
},
|
||||
"admin_endpoint": {
|
||||
"type": "string",
|
||||
"description": "Garage Admin API endpoint",
|
||||
"pattern": "^https?://",
|
||||
"default": "http://garage:3903"
|
||||
},
|
||||
"admin_token": {
|
||||
"type": "string",
|
||||
"description": "Admin API bearer token (ignored if existingSecret is configured)",
|
||||
"default": ""
|
||||
},
|
||||
"existingSecret": {
|
||||
"type": "object",
|
||||
"description": "Use an existing Kubernetes secret for the admin token",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the existing secret containing the admin token",
|
||||
"default": ""
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Key within the secret that contains the admin token value",
|
||||
"default": "admin-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"description": "Authentication configuration (one or both methods can be enabled)",
|
||||
"required": ["admin", "oidc"],
|
||||
"properties": {
|
||||
"jwt_private_key": {
|
||||
"type": "string",
|
||||
"description": "Ed25519 private key for JWT signing in PEM format (EdDSA algorithm). Generate with: openssl genpkey -algorithm ED25519. If not provided, auto-generated on each restart (not recommended for production)",
|
||||
"default": ""
|
||||
},
|
||||
"jwt_private_key_secret": {
|
||||
"type": "object",
|
||||
"description": "Use an existing Kubernetes secret for the JWT private key (recommended)",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the existing secret containing the JWT private key",
|
||||
"default": ""
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Key within the secret that contains the JWT private key value",
|
||||
"default": "jwt-key.pem"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"type": "object",
|
||||
"description": "Admin authentication settings (username/password)",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable admin authentication",
|
||||
"default": false
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Username for admin login",
|
||||
"default": "admin"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Password for admin login (ignored if existingSecret is configured)",
|
||||
"default": "changeme"
|
||||
},
|
||||
"existingSecret": {
|
||||
"type": "object",
|
||||
"description": "Use an existing Kubernetes secret for the admin password",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the existing secret containing the admin password",
|
||||
"default": ""
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Key within the secret that contains the admin password value",
|
||||
"default": "admin-password"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"oidc": {
|
||||
"type": "object",
|
||||
"description": "OpenID Connect (OIDC) configuration",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable OIDC authentication",
|
||||
"default": false
|
||||
},
|
||||
"provider_name": {
|
||||
"type": "string",
|
||||
"description": "Display name of your OIDC provider",
|
||||
"default": "Keycloak"
|
||||
},
|
||||
"client_id": {
|
||||
"type": "string",
|
||||
"description": "OAuth2 client ID registered with your OIDC provider",
|
||||
"default": "garage-ui"
|
||||
},
|
||||
"client_secret": {
|
||||
"type": "string",
|
||||
"description": "OAuth2 client secret (ignored if existingSecret is configured)",
|
||||
"default": "your-client-secret"
|
||||
},
|
||||
"existingSecret": {
|
||||
"type": "object",
|
||||
"description": "Use an existing Kubernetes secret for the client secret",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the existing secret containing the client secret",
|
||||
"default": ""
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Key within the secret that contains the client secret value",
|
||||
"default": "client-secret"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"description": "OAuth2/OIDC scopes to request during authentication",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["openid", "email", "profile"]
|
||||
},
|
||||
"issuer_url": {
|
||||
"type": "string",
|
||||
"description": "OIDC issuer URL (base URL for OIDC discovery)",
|
||||
"pattern": "^https?://",
|
||||
"default": "https://keycloak.example.com/realms/master"
|
||||
},
|
||||
"auth_url": {
|
||||
"type": "string",
|
||||
"description": "Authorization endpoint URL",
|
||||
"pattern": "^https?://",
|
||||
"default": "https://keycloak.example.com/realms/master/protocol/openid-connect/auth"
|
||||
},
|
||||
"token_url": {
|
||||
"type": "string",
|
||||
"description": "Token endpoint URL",
|
||||
"pattern": "^https?://",
|
||||
"default": "https://keycloak.example.com/realms/master/protocol/openid-connect/token"
|
||||
},
|
||||
"userinfo_url": {
|
||||
"type": "string",
|
||||
"description": "User info endpoint URL",
|
||||
"pattern": "^https?://",
|
||||
"default": "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo"
|
||||
},
|
||||
"skip_issuer_check": {
|
||||
"type": "boolean",
|
||||
"description": "Skip issuer validation (not recommended for production)",
|
||||
"default": false
|
||||
},
|
||||
"skip_expiry_check": {
|
||||
"type": "boolean",
|
||||
"description": "Skip token expiry validation (not recommended for production)",
|
||||
"default": false
|
||||
},
|
||||
"email_attribute": {
|
||||
"type": "string",
|
||||
"description": "Claim containing user's email address",
|
||||
"default": "email"
|
||||
},
|
||||
"username_attribute": {
|
||||
"type": "string",
|
||||
"description": "Claim containing user's username/login ID",
|
||||
"default": "preferred_username"
|
||||
},
|
||||
"name_attribute": {
|
||||
"type": "string",
|
||||
"description": "Claim containing user's display name",
|
||||
"default": "name"
|
||||
},
|
||||
"role_attribute_path": {
|
||||
"type": "string",
|
||||
"description": "Path to roles in the OIDC token claims",
|
||||
"default": "resource_access.garage-ui.roles"
|
||||
},
|
||||
"admin_role": {
|
||||
"type": "string",
|
||||
"description": "Role name that grants admin privileges",
|
||||
"default": "admin"
|
||||
},
|
||||
"tls_skip_verify": {
|
||||
"type": "boolean",
|
||||
"description": "Skip TLS certificate verification (only for testing)",
|
||||
"default": false
|
||||
},
|
||||
"session_max_age": {
|
||||
"type": "integer",
|
||||
"description": "Session validity duration in seconds",
|
||||
"minimum": 60,
|
||||
"default": 86400
|
||||
},
|
||||
"cookie_name": {
|
||||
"type": "string",
|
||||
"description": "Name of the session cookie",
|
||||
"default": "garage_session"
|
||||
},
|
||||
"cookie_secure": {
|
||||
"type": "boolean",
|
||||
"description": "Only send cookie over HTTPS connections",
|
||||
"default": true
|
||||
},
|
||||
"cookie_http_only": {
|
||||
"type": "boolean",
|
||||
"description": "Prevent JavaScript access to the cookie",
|
||||
"default": true
|
||||
},
|
||||
"cookie_same_site": {
|
||||
"type": "string",
|
||||
"description": "SameSite cookie attribute for CSRF protection",
|
||||
"enum": ["lax", "strict", "none"],
|
||||
"default": "lax"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cors": {
|
||||
"type": "object",
|
||||
"description": "CORS (Cross-Origin Resource Sharing) configuration",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable CORS",
|
||||
"default": true
|
||||
},
|
||||
"allowed_origins": {
|
||||
"type": "array",
|
||||
"description": "List of allowed origins",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["*"]
|
||||
},
|
||||
"allowed_methods": {
|
||||
"type": "array",
|
||||
"description": "HTTP methods allowed in CORS requests",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||
},
|
||||
"default": ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||
},
|
||||
"allowed_headers": {
|
||||
"type": "array",
|
||||
"description": "HTTP headers allowed in CORS requests",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["Origin", "Content-Type", "Accept", "Authorization"]
|
||||
},
|
||||
"allow_credentials": {
|
||||
"type": "boolean",
|
||||
"description": "Allow credentials in CORS requests (cannot be true when allowed_origins contains '*')",
|
||||
"default": false
|
||||
},
|
||||
"max_age": {
|
||||
"type": "integer",
|
||||
"description": "Cache duration for CORS preflight responses in seconds",
|
||||
"minimum": 0,
|
||||
"default": 3600
|
||||
}
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"type": "object",
|
||||
"description": "Logging configuration",
|
||||
"required": ["level", "format"],
|
||||
"properties": {
|
||||
"level": {
|
||||
"type": "string",
|
||||
"description": "Log verbosity level",
|
||||
"enum": ["debug", "info", "warn", "error"],
|
||||
"default": "info"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"description": "Log output format",
|
||||
"enum": ["json", "text"],
|
||||
"default": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"podAnnotations": {
|
||||
"type": "object",
|
||||
"description": "Annotations to add to the pod",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"podSecurityContext": {
|
||||
"type": "object",
|
||||
"description": "Pod-level security context",
|
||||
"properties": {
|
||||
"runAsNonRoot": {
|
||||
"type": "boolean",
|
||||
"description": "Run containers as non-root user",
|
||||
"default": true
|
||||
},
|
||||
"runAsUser": {
|
||||
"type": "integer",
|
||||
"description": "User ID to run containers as",
|
||||
"minimum": 0,
|
||||
"default": 1000
|
||||
},
|
||||
"fsGroup": {
|
||||
"type": "integer",
|
||||
"description": "Group ID for filesystem access",
|
||||
"minimum": 0,
|
||||
"default": 1000
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityContext": {
|
||||
"type": "object",
|
||||
"description": "Container-level security context",
|
||||
"properties": {
|
||||
"allowPrivilegeEscalation": {
|
||||
"type": "boolean",
|
||||
"description": "Allow privilege escalation",
|
||||
"default": false
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"drop": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["ALL"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"readOnlyRootFilesystem": {
|
||||
"type": "boolean",
|
||||
"description": "Mount root filesystem as read-only",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"service": {
|
||||
"type": "object",
|
||||
"description": "Kubernetes Service configuration",
|
||||
"required": ["type", "port"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Service type",
|
||||
"enum": ["ClusterIP", "NodePort", "LoadBalancer"],
|
||||
"default": "ClusterIP"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"description": "Port the service listens on",
|
||||
"minimum": 1,
|
||||
"maximum": 65535,
|
||||
"default": 80
|
||||
}
|
||||
}
|
||||
},
|
||||
"ingress": {
|
||||
"type": "object",
|
||||
"description": "Ingress configuration",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable ingress",
|
||||
"default": false
|
||||
},
|
||||
"className": {
|
||||
"type": "string",
|
||||
"description": "Ingress class name",
|
||||
"default": "nginx"
|
||||
},
|
||||
"annotations": {
|
||||
"type": "object",
|
||||
"description": "Additional annotations for the ingress",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"description": "Hostname and path configuration",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["host"],
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Hostname for the ingress"
|
||||
},
|
||||
"paths": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["path", "pathType"],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "URL path"
|
||||
},
|
||||
"pathType": {
|
||||
"type": "string",
|
||||
"description": "Path type",
|
||||
"enum": ["Prefix", "Exact", "ImplementationSpecific"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": [
|
||||
{
|
||||
"host": "garage-ui.local",
|
||||
"paths": [
|
||||
{
|
||||
"path": "/",
|
||||
"pathType": "Prefix"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tls": {
|
||||
"type": "array",
|
||||
"description": "TLS/SSL configuration",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"secretName": {
|
||||
"type": "string",
|
||||
"description": "Name of the TLS secret"
|
||||
},
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"type": "object",
|
||||
"description": "CPU and memory resource limits and requests",
|
||||
"properties": {
|
||||
"limits": {
|
||||
"type": "object",
|
||||
"description": "Maximum resources the container can use",
|
||||
"properties": {
|
||||
"cpu": {
|
||||
"type": "string",
|
||||
"description": "Maximum CPU cores",
|
||||
"pattern": "^[0-9]+(m|[0-9]*\\.?[0-9]+)?$",
|
||||
"default": "500m"
|
||||
},
|
||||
"memory": {
|
||||
"type": "string",
|
||||
"description": "Maximum memory",
|
||||
"pattern": "^[0-9]+(Ki|Mi|Gi|Ti|Pi|Ei)?$",
|
||||
"default": "512Mi"
|
||||
}
|
||||
}
|
||||
},
|
||||
"requests": {
|
||||
"type": "object",
|
||||
"description": "Guaranteed resources allocated to the container",
|
||||
"properties": {
|
||||
"cpu": {
|
||||
"type": "string",
|
||||
"description": "Guaranteed CPU",
|
||||
"pattern": "^[0-9]+(m|[0-9]*\\.?[0-9]+)?$",
|
||||
"default": "100m"
|
||||
},
|
||||
"memory": {
|
||||
"type": "string",
|
||||
"description": "Guaranteed memory",
|
||||
"pattern": "^[0-9]+(Ki|Mi|Gi|Ti|Pi|Ei)?$",
|
||||
"default": "128Mi"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"livenessProbe": {
|
||||
"type": "object",
|
||||
"description": "Liveness probe configuration",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable liveness probe",
|
||||
"default": true
|
||||
},
|
||||
"httpGet": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Health check endpoint path",
|
||||
"default": "/health"
|
||||
},
|
||||
"port": {
|
||||
"type": "string",
|
||||
"description": "Port name",
|
||||
"default": "http"
|
||||
}
|
||||
}
|
||||
},
|
||||
"initialDelaySeconds": {
|
||||
"type": "integer",
|
||||
"description": "Wait time before starting liveness checks",
|
||||
"minimum": 0,
|
||||
"default": 30
|
||||
},
|
||||
"periodSeconds": {
|
||||
"type": "integer",
|
||||
"description": "How often to perform the probe",
|
||||
"minimum": 1,
|
||||
"default": 10
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"type": "integer",
|
||||
"description": "Maximum time to wait for probe completion",
|
||||
"minimum": 1,
|
||||
"default": 3
|
||||
},
|
||||
"failureThreshold": {
|
||||
"type": "integer",
|
||||
"description": "Consecutive failures before restarting",
|
||||
"minimum": 1,
|
||||
"default": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"readinessProbe": {
|
||||
"type": "object",
|
||||
"description": "Readiness probe configuration",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable readiness probe",
|
||||
"default": true
|
||||
},
|
||||
"httpGet": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Readiness check endpoint path",
|
||||
"default": "/health"
|
||||
},
|
||||
"port": {
|
||||
"type": "string",
|
||||
"description": "Port name",
|
||||
"default": "http"
|
||||
}
|
||||
}
|
||||
},
|
||||
"initialDelaySeconds": {
|
||||
"type": "integer",
|
||||
"description": "Wait time before starting readiness checks",
|
||||
"minimum": 0,
|
||||
"default": 10
|
||||
},
|
||||
"periodSeconds": {
|
||||
"type": "integer",
|
||||
"description": "How often to perform the probe",
|
||||
"minimum": 1,
|
||||
"default": 5
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"type": "integer",
|
||||
"description": "Maximum time to wait for probe completion",
|
||||
"minimum": 1,
|
||||
"default": 3
|
||||
},
|
||||
"failureThreshold": {
|
||||
"type": "integer",
|
||||
"description": "Consecutive failures before marking as not ready",
|
||||
"minimum": 1,
|
||||
"default": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"serviceMonitor": {
|
||||
"type": "object",
|
||||
"description": "ServiceMonitor for Prometheus Operator",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable ServiceMonitor creation",
|
||||
"default": false
|
||||
},
|
||||
"interval": {
|
||||
"type": "string",
|
||||
"description": "How often Prometheus should scrape metrics",
|
||||
"pattern": "^[0-9]+(s|m|h)$",
|
||||
"default": "30s"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Metrics endpoint path",
|
||||
"default": "/api/v1/monitoring/metrics"
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"description": "Additional labels for the ServiceMonitor",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"networkPolicy": {
|
||||
"type": "object",
|
||||
"description": "NetworkPolicy configuration",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable NetworkPolicy creation",
|
||||
"default": false
|
||||
},
|
||||
"policyTypes": {
|
||||
"type": "array",
|
||||
"description": "Types of policies to enforce",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["Ingress", "Egress"]
|
||||
},
|
||||
"default": ["Ingress", "Egress"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodeSelector": {
|
||||
"type": "object",
|
||||
"description": "Node selector labels",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"tolerations": {
|
||||
"type": "array",
|
||||
"description": "Tolerations for pod scheduling",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"enum": ["Exists", "Equal"]
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"effect": {
|
||||
"type": "string",
|
||||
"enum": ["NoSchedule", "PreferNoSchedule", "NoExecute"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"affinity": {
|
||||
"type": "object",
|
||||
"description": "Affinity rules for pod scheduling",
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
# Default values for garage-ui
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: noooste/garage-ui
|
||||
pullPolicy: IfNotPresent
|
||||
# Overrides the image tag whose default is the chart appVersion
|
||||
tag: ""
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
config:
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
environment: "production"
|
||||
domain: "garage-ui.example.com"
|
||||
protocol: "http"
|
||||
# Full external URL (required for OIDC)
|
||||
root_url: "https://garage-ui.example.com"
|
||||
# Request size limits (in bytes)
|
||||
max_body_size: 314572800 # 300MB
|
||||
max_header_size: 1048576 # 1MB
|
||||
read_buffer_size: 4096 # 4KB
|
||||
write_buffer_size: 4096 # 4KB
|
||||
|
||||
garage:
|
||||
# Garage S3 API endpoint
|
||||
endpoint: "http://garage:3900"
|
||||
region: "garage"
|
||||
# Garage Admin API endpoint
|
||||
admin_endpoint: "http://garage:3903"
|
||||
# Admin API bearer token
|
||||
admin_token: ""
|
||||
# Use existing secret for admin token (recommended)
|
||||
existingSecret:
|
||||
name: ""
|
||||
key: "admin-token"
|
||||
|
||||
auth:
|
||||
# Ed25519 private key for JWT signing (PEM format)
|
||||
# Generate with: openssl genpkey -algorithm ED25519
|
||||
# If not provided and no existing secret is specified, a key will be auto-generated
|
||||
# and persisted in a Kubernetes secret (recommended for production)
|
||||
jwt_private_key: ""
|
||||
# Use existing secret for JWT private key (optional)
|
||||
# If not specified, the chart will auto-generate a secret on first install
|
||||
jwt_private_key_secret:
|
||||
name: ""
|
||||
key: "jwt-key.pem"
|
||||
|
||||
# Admin authentication (username/password)
|
||||
admin:
|
||||
enabled: false
|
||||
username: "admin"
|
||||
password: "changeme"
|
||||
existingSecret:
|
||||
name: ""
|
||||
key: "admin-password"
|
||||
|
||||
# OIDC authentication (Keycloak, Auth0, Okta, etc.)
|
||||
# NOTE: Requires server.root_url to be set
|
||||
oidc:
|
||||
enabled: false
|
||||
provider_name: "Keycloak"
|
||||
client_id: "garage-ui"
|
||||
client_secret: "your-client-secret"
|
||||
existingSecret:
|
||||
name: ""
|
||||
key: "client-secret"
|
||||
scopes:
|
||||
- openid
|
||||
- email
|
||||
- profile
|
||||
# OIDC provider endpoints
|
||||
issuer_url: "https://keycloak.example.com/realms/master"
|
||||
auth_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/auth"
|
||||
token_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/token"
|
||||
userinfo_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo"
|
||||
# Validation settings
|
||||
skip_issuer_check: false
|
||||
skip_expiry_check: false
|
||||
# User attribute mappings
|
||||
email_attribute: "email"
|
||||
username_attribute: "preferred_username"
|
||||
name_attribute: "name"
|
||||
# Role-based access control
|
||||
role_attribute_path: "resource_access.garage-ui.roles"
|
||||
admin_role: "admin"
|
||||
# TLS settings
|
||||
tls_skip_verify: false
|
||||
# Session settings
|
||||
session_max_age: 86400 # 24 hours
|
||||
cookie_name: "garage_session"
|
||||
cookie_secure: true
|
||||
cookie_http_only: true
|
||||
cookie_same_site: "lax"
|
||||
|
||||
# CORS configuration
|
||||
cors:
|
||||
enabled: true
|
||||
allowed_origins:
|
||||
- "*"
|
||||
allowed_methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
allowed_headers:
|
||||
- Origin
|
||||
- Content-Type
|
||||
- Accept
|
||||
- Authorization
|
||||
allow_credentials: false
|
||||
max_age: 3600
|
||||
|
||||
logging:
|
||||
# Options: debug, info, warn, error
|
||||
level: "info"
|
||||
# Options: json, text
|
||||
format: "json"
|
||||
|
||||
# Pod annotations
|
||||
podAnnotations: {}
|
||||
|
||||
# Pod security context
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
fsGroup: 1000
|
||||
|
||||
# Container security context
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: false
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: "nginx"
|
||||
annotations: {}
|
||||
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
# nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
hosts:
|
||||
- host: garage-ui.local
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
# - secretName: garage-ui-tls
|
||||
# hosts:
|
||||
# - garage-ui.local
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
|
||||
livenessProbe:
|
||||
enabled: true
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
enabled: true
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
# ServiceMonitor for Prometheus Operator
|
||||
serviceMonitor:
|
||||
enabled: false
|
||||
interval: 30s
|
||||
path: /api/v1/monitoring/metrics
|
||||
labels: {}
|
||||
|
||||
# NetworkPolicy
|
||||
networkPolicy:
|
||||
enabled: false
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
|
||||
# Node labels for pod assignment
|
||||
nodeSelector: {}
|
||||
|
||||
# Tolerations for pod assignment
|
||||
tolerations: []
|
||||
|
||||
# Affinity for pod assignment
|
||||
affinity: {}
|
||||
Reference in New Issue
Block a user