Compare commits

...

28 Commits

Author SHA1 Message Date
Noooste 43ba68a4f9 feat: update directory creation to include .keep file for empty directories
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-13 23:42:38 +02:00
Noooste a0458fb5d0 chore: bump version to 0.2.1 and update badges in README.md
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-13 22:23:45 +02:00
Noste 6dc0feb374 Merge pull request #15 from Noooste/14-auth-oidc-configuration
fix: Enforce admin role
2026-04-13 21:58:15 +02:00
Noooste 33d8705431 feat: enforce admin role validation in OIDC authentication flow
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-13 21:56:54 +02:00
Noste e7db44f45d chore: update version badges in README.md to 0.2.0
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-03-07 14:41:38 +01:00
Noste 126e138382 chore: bump version to 0.2.0 and update appVersion in Chart.yaml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-03-07 14:25:00 +01:00
Noste 2efb16e248 Merge pull request #11 from Noooste/4-static-website-options
Add static option on the UI
2026-03-07 14:16:57 +01:00
Noste d494c811a0 feat: add website hosting configuration for buckets
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-03-07 13:53:52 +01:00
Noste e800ac0ed1 Merge pull request #10 from Noooste/clean-frontend
Clean frontend
2026-03-07 11:20:08 +01:00
Noste 26ae2ef515 Merge pull request #9 from Noooste/8-feature-display-version
Add Garage and UI Version on the dashboard
2026-03-07 11:19:56 +01:00
Noste 4486697ca5 refactor: simplify access key loading in BucketSettingsDialog and enhance last modified tooltip in ObjectsTable
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-03-07 11:18:14 +01:00
Noste 94ad142edf feat: add health API to retrieve application version and display in sidebar
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-03-07 11:13:33 +01:00
Noste b8b0b6b0fa feat: enhance S3 service to use bucket-specific MinIO client for object retrieval and deletion
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-03-07 10:56:48 +01:00
Noste 8c72a7fb75 Merge pull request #6 from mkm29/fix/remediate-vulns
fix(deps): remediate critical and high vulnerabilities
2026-03-06 16:34:30 +01:00
Mitch Murphy 7df74cd7e8 Update dependencies in go.mod and go.sum
- Upgraded github.com/gofiber/fiber/v3 from v3.0.0-rc.3 to v3.1.0
- Upgraded github.com/golang-jwt/jwt/v5 from v5.3.0 to v5.3.1
- Upgraded github.com/minio/minio-go/v7 from v7.0.97 to v7.0.98
- Updated golang.org/x/oauth2 from v0.34.0 to v0.35.0
- Downgraded github.com/cloudflare/circl from v1.6.2 to v1.6.1
- Downgraded github.com/sagikazarmark/locafero from v0.12.0 to v0.11.0
- Updated various indirect dependencies including go-openapi packages and others
2026-03-01 23:31:17 -05:00
Noste 8f81c7ffea refactor: update logging configuration
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-01-18 20:36:10 +01:00
Noste f6fe310e3e bump version to 0.1.13 and app version to v0.1.2
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 16:02:04 +01:00
Noste a59caa41da refactor: remove unused bucket policies section from AccessControl and README
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:59:17 +01:00
Noste 7d4c7f51e3 bump version to 0.1.12
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:44:42 +01:00
Noste 8063ae18a8 feat: enhance Ed25519 private key parsing to support PKCS#8 format
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:42:08 +01:00
Noste b8056c6d79 feat: bump version to 0.1.11
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:38:08 +01:00
Noste b1b6e02a19 refactor: set jwt secret generation directly in secret.yaml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:37:51 +01:00
Noste 65447b927a feat: refactor JWT key generation to create a Kubernetes secret directly; remove job and RBAC resources
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:37:12 +01:00
Noste bde726fedf feat: bump version to 0.1.10; update version badge in README
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:31:33 +01:00
Noste 27cca273d5 feat: add JWT key generator job and associated RBAC resources; create Kubernetes secret for JWT private key
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:31:12 +01:00
Noste c1a1d64928 feat: bump version to 0.1.9; update JWT key handling in deployment and secret management; enhance README with JWT key management instructions
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:28:51 +01:00
Noste 948bd06a3e Create CONTRIBUTING.md for project guidelines
Added a comprehensive CONTRIBUTING.md file to guide contributors on how to participate in the Garage UI project, including sections on reporting bugs, suggesting enhancements, and the code of conduct.
2025-12-26 12:53:12 +01:00
Noste c4f7b0ccbc feat: update README to simplify descriptions and enhance clarity; improve quick start instructions
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 12:43:41 +01:00
46 changed files with 1122 additions and 1092 deletions
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

+1
View File
@@ -51,6 +51,7 @@ jobs:
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
+132
View File
@@ -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.
+7 -5
View File
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:25-alpine3.22 AS frontend-builder
FROM --platform=$BUILDPLATFORM node:25-alpine3.23 AS frontend-builder
WORKDIR /app/frontend
@@ -11,7 +11,7 @@ COPY frontend/ .
RUN npm run build
FROM --platform=$BUILDPLATFORM 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
@@ -29,14 +29,16 @@ RUN --mount=type=cache,target=/go/pkg/mod \
COPY backend .
ARG VERSION=dev
RUN --mount=type=cache,target=/root/.cache/go-build \
swag init
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 -o garage-ui .
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -ldflags "-X main.version=${VERSION}" -o garage-ui .
FROM alpine:3.22
FROM alpine:3.23.3
WORKDIR /app
@@ -53,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"]
+102 -578
View File
@@ -1,6 +1,6 @@
# Garage UI
A modern, full-stack web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems. Built with React, TypeScript, Go, and Fiber for production-ready S3-compatible storage management.
A web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) object storage clusters.
[![Docker Build](https://github.com/Noooste/garage-ui/actions/workflows/build.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/build.yml)
[![Helm Chart](https://github.com/Noooste/garage-ui/actions/workflows/release.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/release.yml)
@@ -9,665 +9,189 @@ A modern, full-stack web interface for managing [Garage](https://garagehq.deuxfl
[![Node Version](https://img.shields.io/badge/Node-25%2B-339933?logo=node.js)](https://nodejs.org/)
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/garage-ui)](https://artifacthub.io/packages/search?repo=garage-ui)
Garage UI provides a comprehensive dashboard for managing your Garage S3 storage cluster, featuring bucket management, object operations, user access control, and real-time cluster monitoring—all through an intuitive web interface.
---
<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 Management** - Create, configure, and delete S3 buckets with visual controls
- **Object Operations** - Upload, download, and manage objects with drag-and-drop support
- **User & Access Control** - Manage access keys, permissions, and user credentials
- **Cluster Monitoring** - Real-time cluster health metrics, node status, and performance statistics
- **Multi-Auth Support** - Flexible authentication with None, Basic Auth, and OIDC/OAuth2 modes
- **Modern UI/UX** - Responsive React interface with dark mode, built on Tailwind CSS and shadcn/ui
- **Production Ready** - Docker/Kubernetes deployment, health checks, and Prometheus metrics
- **API-First Design** - RESTful API with Swagger/OpenAPI documentation
---
- Bucket and object management
- User access control
- Cluster monitoring
- Multiple authentication options (none/basic/OIDC)
- Drag-and-drop file uploads
## Quick Start
### Prerequisites
- **Garage S3** storage cluster running (v2.0.0+)
- **Docker** & Docker Compose (recommended) OR
- **Go** 1.25+ and **Node.js** 25+ (for development)
- Docker & Docker Compose
- Garage S3 cluster (v2.1.0+) or use the included setup
### Using Docker Compose (Recommended)
1. **Clone the repository:**
### 1. Clone & Setup
```bash
git clone https://github.com/Noooste/garage-ui.git
cd garage-ui
```
2. **Configure the application:**
### 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 credentials
```
3. **Start the services:**
Edit `config.yaml` with your Garage endpoints and admin token (from `garage.toml`).
### 4. Start UI
```bash
docker-compose up -d
docker-compose up -d garage-ui
```
4. **Access the UI:**
Open your browser to [http://localhost:8080](http://localhost:8080)
> The Docker Compose setup includes both Garage and Garage UI services for quick local development.
### Using Docker
```bash
docker run -d \
-p 8080:8080 \
-v $(pwd)/config.yaml:/app/config.yaml \
-e GARAGE_UI_GARAGE_ENDPOINT=http://your-garage:3900 \
-e GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://your-garage:3903 \
noooste/garage-ui:latest
```
---
## Installation
### Docker Deployment
**Pull the latest image:**
```bash
docker pull noooste/garage-ui:latest
```
The Docker image is multi-platform, supporting `linux/amd64` and `linux/arm64`.
### Kubernetes Deployment with Helm
```bash
# Add the Helm repository (if available)
helm repo add garage-ui https://helm.noste.dev/
# Install the chart
helm install garage-ui garage-ui/garage-ui \
--set garage.endpoint=http://garage:3900 \
--set garage.adminEndpoint=http://garage:3903 \
--set garage.adminToken=your-admin-token
```
Or use the local Helm chart:
```bash
helm install garage-ui ./helm/garage-ui -f custom-values.yaml
```
### Building from Source
**Backend:**
```bash
cd backend
go mod download
swag init # Generate API docs
go build -o garage-ui .
```
**Frontend:**
```bash
cd frontend
npm install
npm run build
```
**Run:**
```bash
# From project root
./backend/garage-ui --config config.yaml
```
---
Access at http://localhost:8080
## Configuration
Garage UI can be configured via YAML file or environment variables.
### Configuration File
Create a `config.yaml` based on the provided example:
Minimum required config:
```yaml
# Server configuration
server:
host: "0.0.0.0"
port: 8080
environment: "production"
# Garage S3 Configuration
garage:
endpoint: "garage:3900"
region: "eu-west-1" # Ensure to match Garage region from garage.toml
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token-here"
# Authentication Configuration
# You can enable one or both authentication methods
auth:
# Admin authentication (username/password)
admin:
enabled: false # Set to true to enable admin login
username: "admin"
password: "secure-password"
# OIDC authentication (Keycloak, Auth0, etc.)
oidc:
enabled: false # Set to true to enable OIDC login
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
issuer_url: "https://auth.example.com/realms/master"
auth_url: "https://auth.example.com/realms/master/protocol/openid-connect/auth"
token_url: "https://auth.example.com/realms/master/protocol/openid-connect/token"
admin_token: "your-admin-token"
region: "garage"
```
See [`config.yaml.example`](config.yaml.example) for all available options.
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
All configuration can be set via environment variables with the prefix `GARAGE_UI_`:
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_ENDPOINT=http://garage:3903
GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
GARAGE_UI_AUTH_ADMIN_ENABLED=true
GARAGE_UI_AUTH_ADMIN_USERNAME=admin
GARAGE_UI_AUTH_ADMIN_PASSWORD=password
GARAGE_UI_AUTH_OIDC_ENABLED=false
GARAGE_UI_LOGGING_LEVEL=info
```
**Configuration Priority:** Environment variables override YAML file settings.
## Deployment
---
### Docker
## Project Structure
```
garage-ui/
├── backend/ # Go backend (Fiber framework)
│ ├── internal/
│ │ ├── auth/ # Authentication services (JWT, OIDC)
│ │ ├── config/ # Configuration loading
│ │ ├── handlers/ # HTTP request handlers
│ │ ├── middleware/ # CORS, auth middleware
│ │ ├── models/ # Data models and types
│ │ ├── routes/ # API route definitions
│ │ └── services/ # Business logic (S3, Garage Admin)
│ ├── pkg/ # Shared packages
│ ├── main.go # Application entry point
│ └── go.mod # Go dependencies
├── frontend/ # React + TypeScript frontend
│ ├── src/
│ │ ├── components/ # Reusable UI components
│ │ │ ├── buckets/ # Bucket-specific components
│ │ │ ├── charts/ # Data visualization
│ │ │ ├── layout/ # App layout and navigation
│ │ │ └── ui/ # shadcn/ui component library
│ │ ├── hooks/ # Custom React hooks
│ │ ├── lib/ # API client and utilities
│ │ ├── pages/ # Page components (Dashboard, Buckets, etc.)
│ │ ├── types/ # TypeScript type definitions
│ │ └── App.tsx # Main application component
│ ├── package.json # Node dependencies
│ └── vite.config.ts # Vite build configuration
├── helm/ # Kubernetes Helm chart
│ └── garage-ui/
│ ├── Chart.yaml # Chart metadata
│ ├── values.yaml # Default values
│ └── templates/ # K8s resource templates
├── .github/workflows/ # CI/CD pipelines
│ ├── build.yml # Docker build and push
│ └── release.yml # Helm chart releases
├── Dockerfile # Multi-stage build
├── docker-compose.yml # Local development stack
└── config.yaml.example # Configuration template
```bash
docker run -d -p 8080:8080 \
-v $(pwd)/config.yaml:/app/config.yaml \
noooste/garage-ui:latest
```
**Key Directories:**
### Kubernetes
- **`backend/internal/handlers/`** - REST API endpoints for buckets, objects, users, cluster, and monitoring
- **`frontend/src/pages/`** - Main application views (Dashboard, Buckets, Cluster, Access Control)
- **`backend/internal/services/`** - Core business logic interfacing with Garage Admin API and S3
- **`frontend/src/lib/api.ts`** - Axios-based API client with automatic error handling
---
```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
### Local Development Setup
**1. Start Garage (using Docker Compose):**
```bash
docker-compose up garage -d
```
**2. Run the backend:**
Backend (Go 1.25+):
```bash
cd backend
go run main.go --config ../config.yaml
```
The backend will start on `http://localhost:8080` with hot-reload (use [air](https://github.com/cosmtrek/air) for auto-reload).
**3. Run the frontend:**
Frontend (Node.js 25+):
```bash
cd frontend
npm install
npm run dev
```
The frontend dev server starts on `http://localhost:3000` with API proxy to backend.
API docs: http://localhost:8080/api/v1/
### Available Scripts
## Garage Configuration
**Backend:**
Garage UI requires these settings in your `garage.toml`:
```bash
go run main.go # Start server
go test ./... # Run tests
swag init # Regenerate API docs
go build -o garage-ui . # Build binary
```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
```
**Frontend:**
**Important:** The `admin_token` and `s3_region` in `garage.toml` must match your Garage UI `config.yaml`.
```bash
npm run dev # Development server (Vite)
npm run build # Production build
npm run lint # ESLint checks
npm run preview # Preview production build
```
### Testing
```bash
# Backend tests
cd backend && go test ./internal/...
# Frontend tests (if available)
cd frontend && npm test
```
### Building Docker Image
```bash
docker build -t garage-ui:dev .
```
The Dockerfile uses a multi-stage build:
1. **Frontend build** - Node.js Alpine to compile React app
2. **Backend build** - Go Alpine to compile binary
3. **Runtime** - Minimal Alpine with ca-certificates
---
## API Documentation
Once the backend is running, access the Swagger UI documentation at:
**http://localhost:8080/api/v1/**
### API Endpoints Overview
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check endpoint |
| `/api/v1/buckets` | GET | List all buckets |
| `/api/v1/buckets` | POST | Create a new bucket |
| `/api/v1/buckets/{name}` | GET | Get bucket details |
| `/api/v1/buckets/{name}` | DELETE | Delete a bucket |
| `/api/v1/buckets/{name}/objects` | GET | List objects in bucket |
| `/api/v1/objects/upload` | POST | Upload object to bucket |
| `/api/v1/objects/download` | GET | Download object |
| `/api/v1/objects/delete` | DELETE | Delete object |
| `/api/v1/users` | GET | List access keys |
| `/api/v1/users` | POST | Create access key |
| `/api/v1/users/{id}` | DELETE | Delete access key |
| `/api/v1/cluster/status` | GET | Cluster node status |
| `/api/v1/cluster/health` | GET | Cluster health metrics |
| `/api/v1/cluster/statistics` | GET | Storage statistics |
| `/api/v1/metrics` | GET | Prometheus metrics |
**Authentication:** Bearer token in `Authorization` header when using OIDC or session-based auth.
---
## Authentication
Garage UI supports flexible authentication with the ability to enable one or both methods simultaneously:
### No Authentication
If neither admin nor OIDC authentication is enabled, the application runs without authentication - suitable for private networks or development.
```yaml
auth:
admin:
enabled: false
oidc:
enabled: false
```
### Admin Authentication
Simple username/password authentication using JWT tokens.
```yaml
auth:
admin:
enabled: true
username: "admin"
password: "your-secure-password"
oidc:
enabled: false
```
### OIDC (OpenID Connect) Authentication
Enterprise-grade authentication with providers like Keycloak, Auth0, Okta, etc.
```yaml
auth:
admin:
enabled: false
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
issuer_url: "https://auth.example.com/realms/master"
auth_url: "https://auth.example.com/realms/master/protocol/openid-connect/auth"
token_url: "https://auth.example.com/realms/master/protocol/openid-connect/token"
userinfo_url: "https://auth.example.com/realms/master/protocol/openid-connect/userinfo"
session_max_age: 86400 # 24 hours
cookie_secure: true # Enable in production with HTTPS
```
### Both Authentication Methods
You can enable both admin and OIDC authentication simultaneously. Users will be presented with both options on the login page:
```yaml
auth:
admin:
enabled: true
username: "admin"
password: "your-secure-password"
oidc:
enabled: true
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
issuer_url: "https://auth.example.com/realms/master"
auth_url: "https://auth.example.com/realms/master/protocol/openid-connect/auth"
token_url: "https://auth.example.com/realms/master/protocol/openid-connect/token"
userinfo_url: "https://auth.example.com/realms/master/protocol/openid-connect/userinfo"
session_max_age: 86400
cookie_secure: true
```
**Role-Based Access (Optional):**
```yaml
auth:
oidc:
role_attribute_path: "resource_access.garage-ui.roles"
admin_role: "admin"
```
---
## Deployment Best Practices
### Production Considerations
1. **Use HTTPS** - Always enable TLS in production:
```yaml
auth:
oidc:
cookie_secure: true
```
2. **Set Strong Admin Token** - Generate a secure token for Garage Admin API:
```bash
openssl rand -base64 32
```
3. **Configure CORS** - Restrict allowed origins:
```yaml
cors:
allowed_origins:
- "https://garage-ui.yourdomain.com"
```
4. **Enable Monitoring** - Expose metrics endpoint for Prometheus:
```yaml
# Metrics available at /api/v1/metrics
```
5. **Resource Limits** - Set appropriate CPU/memory limits in Kubernetes/Docker
### Health Checks
The application provides a health check endpoint:
```bash
curl http://localhost:8080/health
# Response: {"status":"ok","version":"0.1.0"}
```
Docker health check is configured automatically (every 30s).
### Kubernetes Deployment
The Helm chart includes:
- **Deployment** with configurable replicas
- **Service** (ClusterIP/LoadBalancer)
- **Ingress** with TLS support
- **ConfigMap** for configuration
- **NetworkPolicy** for security
- **ServiceMonitor** for Prometheus (optional)
Example custom values:
```yaml
# custom-values.yaml
replicaCount: 2
image:
repository: noooste/garage-ui
tag: latest
garage:
endpoint: "http://garage.storage.svc.cluster.local:3900"
adminEndpoint: "http://garage.storage.svc.cluster.local:3903"
adminToken: "your-secure-token"
ingress:
enabled: true
className: nginx
hosts:
- host: garage-ui.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: garage-ui-tls
hosts:
- garage-ui.example.com
config:
auth:
admin:
enabled: false
oidc:
enabled: true
client_id: garage-ui
client_secret: secret
issuer_url: https://auth.example.com/realms/master
```
Install with:
```bash
helm install garage-ui ./helm/garage-ui -f custom-values.yaml
```
---
## Technology Stack
### Backend
- **[Go](https://go.dev/)** 1.25+ - High-performance backend runtime
- **[Fiber v3](https://gofiber.io/)** - Express-inspired web framework
- **[Viper](https://github.com/spf13/viper)** - Configuration management
- **[Minio Go SDK](https://github.com/minio/minio-go)** - S3 client library
- **[go-oidc](https://github.com/coreos/go-oidc)** - OpenID Connect support
- **[Zerolog](https://github.com/rs/zerolog)** - Structured logging
- **[Swagger/Swag](https://github.com/swaggo/swag)** - API documentation
### Frontend
- **[React](https://react.dev/)** 19 - UI framework
- **[TypeScript](https://www.typescriptlang.org/)** 5.9+ - Type safety
- **[Vite](https://vitejs.dev/)** 7 - Build tool and dev server
- **[React Router](https://reactrouter.com/)** 7 - Client-side routing
- **[TanStack Query](https://tanstack.com/query)** - Server state management
- **[Zustand](https://github.com/pmndrs/zustand)** - Client state management
- **[React Hook Form](https://react-hook-form.com/)** + [Zod](https://zod.dev/) - Form validation
- **[Tailwind CSS](https://tailwindcss.com/)** 4 - Utility-first styling
- **[shadcn/ui](https://ui.shadcn.com/)** - Component library
- **[Lucide Icons](https://lucide.dev/)** - Icon library
- **[Recharts](https://recharts.org/)** - Data visualization
- **[Axios](https://axios-http.com/)** - HTTP client
---
## Contributing
Contributions are welcome! Whether it's bug reports, feature requests, or code contributions, your input helps improve Garage UI.
### How to Contribute
1. **Fork the repository**
2. **Create a feature branch** (`git checkout -b feature/amazing-feature`)
3. **Make your changes**
4. **Commit your changes** (`git commit -m 'Add amazing feature'`)
5. **Push to the branch** (`git push origin feature/amazing-feature`)
6. **Open a Pull Request**
### Development Guidelines
- Follow existing code style (use `gofmt` for Go, ESLint for TypeScript)
- Add tests for new features when possible
- Update documentation for API changes
- Keep commits atomic and descriptive
- Ensure all tests pass before submitting PR
### Reporting Issues
Please open an issue on GitHub with:
- Clear description of the problem
- Steps to reproduce
- Expected vs actual behavior
- Environment details (OS, Docker version, etc.)
---
For complete Garage configuration, see the [official documentation](https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/).
## Troubleshooting
### Common Issues
**Issue: "Failed to connect to Garage Admin API"**
- **Solution:** Verify `garage.admin_endpoint` and `garage.admin_token` in config.yaml
- Check Garage Admin API is accessible: `curl http://garage:3903/status -H "Authorization: Bearer your-token"`
**Issue: "CORS errors in browser console"**
- **Solution:** Configure CORS in config.yaml to allow frontend origin:
```yaml
cors:
allowed_origins:
- "http://localhost:3000" # For dev
```
**Issue: "401 Unauthorized with OIDC"**
- **Solution:** Verify OIDC configuration, especially `issuer_url`, `client_id`, and `client_secret`
- Check provider configuration allows the redirect URL: `http://your-app/auth/callback`
**Issue: "Objects not appearing after upload"**
- **Solution:** Check S3 endpoint connectivity and bucket permissions
- Verify Garage bucket exists: `garage bucket list`
### Debug Mode
Enable debug logging for troubleshooting:
**Connection failed:**
```bash
curl http://localhost:3903/status -H "Authorization: Bearer your-token"
```
**Enable debug logs:**
```yaml
logging:
level: "debug"
format: "json"
format: "text" # or "json"
```
Or via environment variable:
```bash
GARAGE_UI_LOGGING_LEVEL=debug
```
---
## License
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
MIT - see [LICENSE](LICENSE)
---
## Links
## Acknowledgments
- [Garage](https://garagehq.deuxfleurs.fr/) - The lightweight, geo-distributed S3 storage system
- [shadcn/ui](https://ui.shadcn.com/) - Beautiful, accessible React components
- [Fiber](https://gofiber.io/) - Fast and lightweight Go web framework
- All open-source contributors who made this project possible
---
## Support
- **Documentation:** [GitHub Wiki](https://github.com/Noooste/garage-ui/wiki) (coming soon)
- **Issues:** [GitHub Issues](https://github.com/Noooste/garage-ui/issues)
- **Discussions:** [GitHub Discussions](https://github.com/Noooste/garage-ui/discussions)
---
**Made with ❤️ for the Garage community**
- [Issues](https://github.com/Noooste/garage-ui/issues)
- [Contributing](CONTRIBUTING.md)
- [Garage Docs](https://garagehq.deuxfleurs.fr/documentation/)
+27 -34
View File
@@ -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
View File
@@ -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=
+15 -15
View File
@@ -13,8 +13,8 @@ import (
"golang.org/x/oauth2"
)
// AuthService handles authentication operations
type AuthService struct {
// Service handles authentication operations
type Service struct {
authConfig *config.AuthConfig
serverConfig *config.ServerConfig
oidcProvider *oidc.Provider
@@ -32,13 +32,13 @@ type UserInfo struct {
}
// NewAuthService creates a new authentication service
func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*AuthService, error) {
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{
service := &Service{
authConfig: authCfg,
serverConfig: serverCfg,
jwtService: jwtService,
@@ -55,7 +55,7 @@ func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig)
}
// initOIDC initializes the OIDC provider and configuration
func (a *AuthService) initOIDC() error {
func (a *Service) initOIDC() error {
ctx := context.Background()
// Create OIDC provider
@@ -91,7 +91,7 @@ func (a *AuthService) initOIDC() error {
}
// 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),
@@ -136,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")
}
@@ -145,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")
}
@@ -159,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")
}
@@ -192,7 +192,7 @@ func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*Us
}
// 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")
}
@@ -228,7 +228,7 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us
}
// IsAdmin checks if the user has admin role
func (a *AuthService) IsAdmin(userInfo *UserInfo) bool {
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
if a.authConfig.OIDC.AdminRole == "" {
return false
}
@@ -316,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) {
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
+15 -4
View File
@@ -3,6 +3,7 @@ package auth
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
@@ -76,12 +77,22 @@ func parseEd25519PrivateKeyFromPEM(privateKeyPEM string) (ed25519.PrivateKey, er
return nil, fmt.Errorf("failed to decode PEM block")
}
// Check if it's raw Ed25519 private key bytes (64 bytes)
if len(block.Bytes) == ed25519.PrivateKeySize {
return ed25519.PrivateKey(block.Bytes), nil
// 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")
}
return nil, fmt.Errorf("invalid Ed25519 private key format: expected %d bytes, got %d",
// 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))
}
+29 -26
View File
@@ -11,11 +11,11 @@ import (
// AuthHandler handles authentication-related requests
type AuthHandler struct {
cfg *config.Config
authService *auth.AuthService
authService *auth.Service
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(cfg *config.Config, authService *auth.AuthService) *AuthHandler {
func NewAuthHandler(cfg *config.Config, authService *auth.Service) *AuthHandler {
return &AuthHandler{
cfg: cfg,
authService: authService,
@@ -23,12 +23,13 @@ func NewAuthHandler(cfg *config.Config, authService *auth.AuthService) *AuthHand
}
// 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]
//
// @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{
@@ -58,16 +59,17 @@ type LoginBasicRequest struct {
}
// 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]
//
// @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
@@ -107,14 +109,15 @@ func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
}
// 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]
//
// @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")
+81 -5
View File
@@ -67,11 +67,13 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
}
bucketInfo := models.BucketInfo{
Name: bucketName,
CreationDate: adminBucket.Created,
Region: "", // Garage doesn't have regions
ObjectCount: &detailedInfo.Objects,
Size: &detailedInfo.Bytes,
Name: bucketName,
CreationDate: adminBucket.Created,
Region: "",
ObjectCount: &detailedInfo.Objects,
Size: &detailedInfo.Bytes,
WebsiteAccess: detailedInfo.WebsiteAccess,
WebsiteConfig: detailedInfo.WebsiteConfig,
}
buckets = append(buckets, bucketInfo)
@@ -306,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))
}
+7 -4
View File
@@ -1,6 +1,7 @@
package handlers
import (
"bufio"
"io"
"strconv"
"time"
@@ -176,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))
@@ -189,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
+1 -39
View File
@@ -9,7 +9,7 @@ import (
)
// AuthMiddleware supports admin and OIDC authentication
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler {
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.Service) fiber.Handler {
return func(c fiber.Ctx) error {
// If no auth is enabled, allow all requests
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
@@ -61,41 +61,3 @@ func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber
)
}
}
func RequireAuth(cfg *config.AuthConfig) fiber.Handler {
return func(c fiber.Ctx) error {
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
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"`
+7
View File
@@ -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"`
}
+7 -5
View File
@@ -40,11 +40,13 @@ type HealthResponse struct {
// BucketInfo represents information about a bucket
type BucketInfo struct {
Name string `json:"name"`
CreationDate time.Time `json:"creationDate"`
ObjectCount *int64 `json:"objectCount,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
+28 -3
View File
@@ -5,7 +5,7 @@ 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"
@@ -22,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,
@@ -60,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
@@ -241,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 {
@@ -292,7 +317,7 @@ func SetupRoutes(
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()
}
@@ -21,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,
@@ -76,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)
@@ -178,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)
@@ -279,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)
@@ -313,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)
@@ -347,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)
@@ -396,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)
@@ -434,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)
+16 -4
View File
@@ -317,13 +317,19 @@ 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) {
// 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 {
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var getErr error
object, getErr = s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
object, getErr = client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
return getErr
})
if err != nil {
@@ -351,10 +357,16 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
// DeleteObject deletes an object from a bucket
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
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 s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
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)
+44 -27
View File
@@ -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"
)
@@ -55,27 +54,38 @@ 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)
// Determine enabled auth methods for logging
@@ -89,10 +99,10 @@ func main() {
if len(authMethods) == 0 {
authMethods = append(authMethods, "none")
}
log.Printf("Initializing authentication service (enabled: %v)...", authMethods)
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
@@ -121,9 +131,12 @@ func main() {
writeBufferSize = 4096 // 4KB default
}
log.Printf("Server limits - Max body: %d bytes (%.2fMB), Max header: %d bytes (%.2fKB)",
maxBodySize, float64(maxBodySize)/(1024*1024),
maxHeaderSize, float64(maxHeaderSize)/1024)
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{
@@ -136,12 +149,9 @@ func main() {
// 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,
@@ -157,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")
}
}()
@@ -171,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
@@ -190,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{
+5 -6
View File
@@ -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
-1
View File
@@ -91,5 +91,4 @@ func (c *Cache) cleanupExpired() {
}
}
// Global cache instance
var GlobalCache = NewCache()
+4 -3
View File
@@ -103,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"
-4
View File
@@ -41,7 +41,3 @@ services:
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS
-----END PRIVATE KEY-----
# Logging
GARAGE_UI_LOGGING_LEVEL: "info"
GARAGE_UI_LOGGING_FORMAT: "json"
-1
View File
@@ -143,7 +143,6 @@ list: async (): Promise<Bucket[]> => {
- Activate/deactivate keys
- Copy access key IDs
- Permission configuration
- Bucket policies (coming soon)
## Theme Customization
+64 -64
View File
@@ -1530,9 +1530,9 @@
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
"integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.0.tgz",
"integrity": "sha512-dlzb07f5LDY+tzs+iLCSXV2yuhaYfezqyZQc+n6baLECWkOMEWxkECAOnXL0ba7lsA25fM9b2jtzpu/uxo1a7g==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -2358,17 +2358,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz",
"integrity": "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==",
"version": "8.50.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz",
"integrity": "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/type-utils": "8.50.0",
"@typescript-eslint/utils": "8.50.0",
"@typescript-eslint/visitor-keys": "8.50.0",
"@typescript-eslint/scope-manager": "8.50.1",
"@typescript-eslint/type-utils": "8.50.1",
"@typescript-eslint/utils": "8.50.1",
"@typescript-eslint/visitor-keys": "8.50.1",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.1.0"
@@ -2381,7 +2381,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.50.0",
"@typescript-eslint/parser": "^8.50.1",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
@@ -2397,17 +2397,17 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"version": "8.50.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.1.tgz",
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/typescript-estree": "8.50.0",
"@typescript-eslint/visitor-keys": "8.50.0",
"@typescript-eslint/scope-manager": "8.50.1",
"@typescript-eslint/types": "8.50.1",
"@typescript-eslint/typescript-estree": "8.50.1",
"@typescript-eslint/visitor-keys": "8.50.1",
"debug": "^4.3.4"
},
"engines": {
@@ -2423,14 +2423,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz",
"integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==",
"version": "8.50.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.1.tgz",
"integrity": "sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.50.0",
"@typescript-eslint/types": "^8.50.0",
"@typescript-eslint/tsconfig-utils": "^8.50.1",
"@typescript-eslint/types": "^8.50.1",
"debug": "^4.3.4"
},
"engines": {
@@ -2445,14 +2445,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.0.tgz",
"integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==",
"version": "8.50.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.1.tgz",
"integrity": "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/visitor-keys": "8.50.0"
"@typescript-eslint/types": "8.50.1",
"@typescript-eslint/visitor-keys": "8.50.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2463,9 +2463,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz",
"integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==",
"version": "8.50.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.1.tgz",
"integrity": "sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2480,15 +2480,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.0.tgz",
"integrity": "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==",
"version": "8.50.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.1.tgz",
"integrity": "sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/typescript-estree": "8.50.0",
"@typescript-eslint/utils": "8.50.0",
"@typescript-eslint/types": "8.50.1",
"@typescript-eslint/typescript-estree": "8.50.1",
"@typescript-eslint/utils": "8.50.1",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
@@ -2505,9 +2505,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz",
"integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==",
"version": "8.50.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.1.tgz",
"integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2519,16 +2519,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz",
"integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==",
"version": "8.50.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.1.tgz",
"integrity": "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.50.0",
"@typescript-eslint/tsconfig-utils": "8.50.0",
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/visitor-keys": "8.50.0",
"@typescript-eslint/project-service": "8.50.1",
"@typescript-eslint/tsconfig-utils": "8.50.1",
"@typescript-eslint/types": "8.50.1",
"@typescript-eslint/visitor-keys": "8.50.1",
"debug": "^4.3.4",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
@@ -2586,16 +2586,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.0.tgz",
"integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==",
"version": "8.50.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.1.tgz",
"integrity": "sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/typescript-estree": "8.50.0"
"@typescript-eslint/scope-manager": "8.50.1",
"@typescript-eslint/types": "8.50.1",
"@typescript-eslint/typescript-estree": "8.50.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2610,13 +2610,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.0.tgz",
"integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==",
"version": "8.50.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.1.tgz",
"integrity": "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/types": "8.50.1",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@@ -5082,16 +5082,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.0.tgz",
"integrity": "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==",
"version": "8.50.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.1.tgz",
"integrity": "sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "8.50.0",
"@typescript-eslint/typescript-estree": "8.50.0",
"@typescript-eslint/utils": "8.50.0"
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"@typescript-eslint/typescript-estree": "8.50.1",
"@typescript-eslint/utils": "8.50.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -9,7 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
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';
@@ -23,6 +23,7 @@ interface BucketListViewProps {
onOpenSettings: (bucket: Bucket) => void;
onCreateBucket: () => void;
onDeleteBucket: (bucket: Bucket) => void;
onWebsiteSettings: (bucket: Bucket) => void;
}
export function BucketListView({
@@ -34,6 +35,7 @@ export function BucketListView({
onOpenSettings,
onCreateBucket,
onDeleteBucket,
onWebsiteSettings,
}: BucketListViewProps) {
const filteredBuckets = buckets.filter((bucket) =>
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
@@ -94,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>
@@ -123,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>
);
}
@@ -301,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 && (
@@ -3,6 +3,8 @@ import {cn} from '@/lib/utils';
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;
@@ -46,6 +48,24 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
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
className={cn(
@@ -107,6 +127,13 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
</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>
);
}
+41
View File
@@ -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 };
-15
View File
@@ -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 -1
View File
@@ -200,7 +200,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
try {
const dirKey = currentPath ? `${currentPath}${dirName}/` : `${dirName}/`;
await objectsApi.upload(bucketName, dirKey, new File([], ''));
await objectsApi.upload(bucketName, dirKey, new File([], '.keep'));
toast.success(`Directory "${dirName}" created successfully`);
await fetchObjects(currentContinuationToken, true);
return true;
+20
View File
@@ -2,6 +2,7 @@ import axios from 'axios';
import {toast} from 'sonner';
import type {
AccessKey,
ApiResponse,
Bucket,
BucketDetails,
ClusterHealth,
@@ -158,6 +159,14 @@ export const authApi = {
},
};
// 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[]> => {
@@ -192,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
+2 -23
View File
@@ -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';
@@ -369,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">
@@ -568,22 +563,6 @@ 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>
+33
View File
@@ -1,4 +1,5 @@
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, useCreateBucket, useDeleteBucket, useGrantBucketPermission } from '@/hooks/useApi';
@@ -8,8 +9,10 @@ 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,6 +56,7 @@ export function Buckets() {
}, [searchParams]);
// Custom hooks
const queryClient = useQueryClient();
const { data: buckets = [], isLoading: bucketsLoading } = useBuckets();
const createBucketMutation = useCreateBucket();
const deleteBucketMutation = useDeleteBucket();
@@ -139,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 {
@@ -224,6 +249,7 @@ export function Buckets() {
onSearchChange={setSearchQuery}
onViewBucket={handleViewBucket}
onOpenSettings={handleOpenSettings}
onWebsiteSettings={handleOpenWebsiteSettings}
onCreateBucket={() => setCreateDialogOpen(true)}
onDeleteBucket={(bucket) => {
setSelectedBucket(bucket);
@@ -252,6 +278,13 @@ export function Buckets() {
bucket={settingsBucket}
onGrantPermission={grantPermission}
/>
<BucketWebsiteDialog
open={websiteDialogOpen}
onOpenChange={setWebsiteDialogOpen}
bucket={websiteBucket}
onSave={handleSaveWebsite}
/>
</div>
);
}
+5
View File
@@ -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 {
+2 -2
View File
@@ -3,8 +3,8 @@ name: garage-ui
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
icon: https://helm.noste.dev/garage.png
type: application
version: 0.1.8
appVersion: "v0.1.0"
version: 0.2.1
appVersion: "v0.2.1"
keywords:
- garage
- s3
+98 -3
View File
@@ -2,8 +2,8 @@
A Helm chart for deploying [Garage UI](https://github.com/Noooste/garage-ui), a modern web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems.
[![Version](https://img.shields.io/badge/version-0.1.8-blue.svg)](Chart.yaml)
[![App Version](https://img.shields.io/badge/app%20version-v0.1.0-green.svg)](Chart.yaml)
[![Version](https://img.shields.io/badge/version-0.2.1-blue.svg)](Chart.yaml)
[![App Version](https://img.shields.io/badge/app%20version-v0.2.1-green.svg)](Chart.yaml)
## Table of Contents
@@ -815,15 +815,110 @@ The chart will:
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 (requires manual template modification):
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:
+1 -3
View File
@@ -46,7 +46,6 @@ spec:
name: {{ include "garage-ui.fullname" . }}-admin-token
key: admin-token
{{- end }}
{{- if or .Values.config.auth.jwt_private_key .Values.config.auth.jwt_private_key_secret.name }}
- name: GARAGE_UI_AUTH_JWT_PRIVATE_KEY
valueFrom:
secretKeyRef:
@@ -55,9 +54,8 @@ spec:
key: {{ .Values.config.auth.jwt_private_key_secret.key }}
{{- else }}
name: {{ include "garage-ui.fullname" . }}-jwt-key
key: jwt-key
key: jwt-key.pem
{{- end }}
{{- end }}
{{- if .Values.config.auth.oidc.enabled }}
- name: GARAGE_UI_AUTH_OIDC_CLIENT_SECRET
valueFrom:
+22 -1
View File
@@ -45,7 +45,28 @@ 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: {{ .Values.config.auth.jwt_private_key | b64enc | quote }}
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 }}
+4 -2
View File
@@ -43,9 +43,11 @@ config:
auth:
# Ed25519 private key for JWT signing (PEM format)
# Generate with: openssl genpkey -algorithm ED25519
# If not provided, auto-generated on each restart (not recommended for production)
# 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 (recommended)
# 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"