mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
Initial gh-pages
This commit is contained in:
@@ -1,46 +0,0 @@
|
||||
|
||||
# Git files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE files
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Build artifacts
|
||||
bin/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test files
|
||||
*.test
|
||||
coverage.txt
|
||||
coverage.html
|
||||
|
||||
# Frontend development files
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
|
||||
# Backend development files
|
||||
*.log
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Documentation (optional, include if needed in image)
|
||||
# docs/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Development config (you may want a separate config for production)
|
||||
*.local.yaml
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_LOGIN }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_REGISTRY }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
@@ -1,4 +0,0 @@
|
||||
name: release.yml
|
||||
on:
|
||||
|
||||
jobs:
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Code coverage profiles and other test artifacts
|
||||
*.out
|
||||
coverage.*
|
||||
*.coverprofile
|
||||
profile.cov
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env*
|
||||
!config.yaml.example
|
||||
docker-compose.*.yml
|
||||
|
||||
data/
|
||||
meta/
|
||||
garage.toml
|
||||
|
||||
backend/docs/
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
FROM node:25-alpine3.22 AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.25.4-alpine3.22 AS backend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN go install github.com/swaggo/swag/cmd/swag@latest
|
||||
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
|
||||
COPY backend .
|
||||
|
||||
RUN swag init
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o garage-ui .
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk --no-cache add ca-certificates wget
|
||||
|
||||
RUN addgroup -g 1000 garageui && \
|
||||
adduser -D -u 1000 -G garageui garageui
|
||||
|
||||
COPY --from=backend-builder --chown=garageui:garageui /app/garage-ui .
|
||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||
|
||||
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 ["./garage-ui"]
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Noste
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,84 +0,0 @@
|
||||
module Noooste/garage-ui
|
||||
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/Noooste/azuretls-client v1.12.10
|
||||
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/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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Noooste/fhttp v1.0.15 // indirect
|
||||
github.com/Noooste/go-socks4 v0.0.2 // indirect
|
||||
github.com/Noooste/uquic-go v1.0.3 // indirect
|
||||
github.com/Noooste/utls v1.3.20 // indirect
|
||||
github.com/Noooste/websocket v1.0.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/bdandy/go-errors v1.2.2 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gaukas/clienthellod v0.4.2 // indirect
|
||||
github.com/gaukas/godicttls v0.0.4 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
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-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/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/klauspost/crc32 v1.3.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
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
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/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/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.68.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.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
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
-239
@@ -1,239 +0,0 @@
|
||||
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/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=
|
||||
github.com/Noooste/utls v1.3.20/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg=
|
||||
github.com/Noooste/websocket v1.0.3 h1:drW7tvZ3YqzqI9wApnaH1Q0syFMXO7gbLlsBWjZvMNA=
|
||||
github.com/Noooste/websocket v1.0.3/go.mod h1:Qhw0Rtuju/fPPbcb3R5XGq7poa51qPDL462jTltl9nQ=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
||||
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
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=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gaukas/clienthellod v0.4.2 h1:LPJ+LSeqt99pqeCV4C0cllk+pyWmERisP7w6qWr7eqE=
|
||||
github.com/gaukas/clienthellod v0.4.2/go.mod h1:M57+dsu0ZScvmdnNxaxsDPM46WhSEdPYAOdNgfL7IKA=
|
||||
github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk=
|
||||
github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI=
|
||||
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-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-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/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=
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18=
|
||||
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/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/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/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=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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/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=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/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/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=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
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/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/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/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/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/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/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/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/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/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/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=
|
||||
@@ -1,339 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// AuthService handles authentication operations
|
||||
type AuthService struct {
|
||||
config *config.AuthConfig
|
||||
oidcProvider *oidc.Provider
|
||||
oidcVerifier *oidc.IDTokenVerifier
|
||||
oauth2Config *oauth2.Config
|
||||
jwtService *JWTService
|
||||
}
|
||||
|
||||
// UserInfo represents authenticated user information
|
||||
type UserInfo struct {
|
||||
Username string
|
||||
Email string
|
||||
Name string
|
||||
Roles []string
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) {
|
||||
jwtService, err := NewJWTService()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize JWT service: %w", err)
|
||||
}
|
||||
|
||||
service := &AuthService{
|
||||
config: cfg,
|
||||
jwtService: jwtService,
|
||||
}
|
||||
|
||||
// Initialize OIDC if enabled
|
||||
if cfg.Mode == "oidc" && cfg.OIDC.Enabled {
|
||||
if err := service.initOIDC(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OIDC: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// initOIDC initializes the OIDC provider and configuration
|
||||
func (a *AuthService) initOIDC() error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create OIDC provider
|
||||
provider, err := oidc.NewProvider(ctx, a.config.OIDC.IssuerURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OIDC provider: %w", err)
|
||||
}
|
||||
|
||||
a.oidcProvider = provider
|
||||
|
||||
// Create ID token verifier
|
||||
verifierConfig := &oidc.Config{
|
||||
ClientID: a.config.OIDC.ClientID,
|
||||
SkipIssuerCheck: a.config.OIDC.SkipIssuerCheck,
|
||||
SkipExpiryCheck: a.config.OIDC.SkipExpiryCheck,
|
||||
}
|
||||
a.oidcVerifier = provider.Verifier(verifierConfig)
|
||||
|
||||
// Create OAuth2 config
|
||||
a.oauth2Config = &oauth2.Config{
|
||||
ClientID: a.config.OIDC.ClientID,
|
||||
ClientSecret: a.config.OIDC.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: a.config.OIDC.Scopes,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateBasicAuth validates basic authentication credentials
|
||||
func (a *AuthService) ValidateBasicAuth(username, password string) bool {
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
usernameMatch := subtle.ConstantTimeCompare(
|
||||
[]byte(username),
|
||||
[]byte(a.config.Basic.Username),
|
||||
) == 1
|
||||
|
||||
passwordMatch := subtle.ConstantTimeCompare(
|
||||
[]byte(password),
|
||||
[]byte(a.config.Basic.Password),
|
||||
) == 1
|
||||
|
||||
return usernameMatch && passwordMatch
|
||||
}
|
||||
|
||||
// ParseBasicAuth parses the Authorization header for basic auth
|
||||
func ParseBasicAuth(authHeader string) (username, password string, ok bool) {
|
||||
if authHeader == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Check if it's a Basic auth header
|
||||
const prefix = "Basic "
|
||||
if !strings.HasPrefix(authHeader, prefix) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Decode base64 credentials
|
||||
encoded := authHeader[len(prefix):]
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Split username:password
|
||||
credentials := string(decoded)
|
||||
parts := strings.SplitN(credentials, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
// GetAuthorizationURL returns the OIDC authorization URL for login
|
||||
func (a *AuthService) GetAuthorizationURL(state string) (string, error) {
|
||||
if a.oauth2Config == nil {
|
||||
return "", fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
|
||||
return a.oauth2Config.AuthCodeURL(state), nil
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges an authorization code for tokens
|
||||
func (a *AuthService) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
|
||||
if a.oauth2Config == nil {
|
||||
return nil, fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
|
||||
token, err := a.oauth2Config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// VerifyIDToken verifies an OIDC ID token and extracts user info
|
||||
func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserInfo, error) {
|
||||
if a.oidcVerifier == nil {
|
||||
return nil, fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
|
||||
// Verify the ID token
|
||||
idToken, err := a.oidcVerifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify ID token: %w", err)
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
var claims map[string]interface{}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse claims: %w", err)
|
||||
}
|
||||
|
||||
// Extract user information using configured attributes
|
||||
userInfo := &UserInfo{
|
||||
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
|
||||
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
|
||||
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
|
||||
}
|
||||
|
||||
// Extract roles if configured
|
||||
if a.config.OIDC.RoleAttributePath != "" {
|
||||
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
// GetUserInfo retrieves user information from the OIDC provider
|
||||
func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
|
||||
if a.oidcProvider == nil {
|
||||
return nil, fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
|
||||
// Create OAuth2 token source
|
||||
tokenSource := a.oauth2Config.TokenSource(ctx, token)
|
||||
|
||||
// Get user info from the provider
|
||||
userInfoEndpoint, err := a.oidcProvider.UserInfo(ctx, tokenSource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user info: %w", err)
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
var claims map[string]interface{}
|
||||
if err := userInfoEndpoint.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse user info claims: %w", err)
|
||||
}
|
||||
|
||||
// Build user info
|
||||
userInfo := &UserInfo{
|
||||
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
|
||||
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
|
||||
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
|
||||
}
|
||||
|
||||
// Extract roles if configured
|
||||
if a.config.OIDC.RoleAttributePath != "" {
|
||||
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user has admin role
|
||||
func (a *AuthService) IsAdmin(userInfo *UserInfo) bool {
|
||||
if a.config.OIDC.AdminRole == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, role := range userInfo.Roles {
|
||||
if role == a.config.OIDC.AdminRole {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// extractClaim extracts a string claim from the claims map
|
||||
func extractClaim(claims map[string]interface{}, key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, ok := claims[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
// extractRoles extracts roles from nested claim path (e.g., "resource_access.garage-ui.roles")
|
||||
func extractRoles(claims map[string]interface{}, path string) []string {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Split the path by dots to navigate nested claims
|
||||
parts := strings.Split(path, ".")
|
||||
|
||||
current := claims
|
||||
for i, part := range parts {
|
||||
value, ok := current[part]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if i == len(parts)-1 {
|
||||
return extractStringArray(value)
|
||||
}
|
||||
|
||||
// Navigate to next level
|
||||
next, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
current = next
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractStringArray converts an interface{} to []string if possible
|
||||
func extractStringArray(value interface{}) []string {
|
||||
// Try direct string array
|
||||
if strArray, ok := value.([]string); ok {
|
||||
return strArray
|
||||
}
|
||||
|
||||
// Try interface array and convert to strings
|
||||
if array, ok := value.([]interface{}); ok {
|
||||
result := make([]string, 0, len(array))
|
||||
for _, item := range array {
|
||||
if str, ok := item.(string); ok {
|
||||
result = append(result, str)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateStateToken generates a secure CSRF state token
|
||||
func (a *AuthService) GenerateStateToken() (string, error) {
|
||||
return a.jwtService.GenerateStateToken()
|
||||
}
|
||||
|
||||
// ValidateAndConsumeState validates and consumes a CSRF state token
|
||||
func (a *AuthService) ValidateAndConsumeState(token string) bool {
|
||||
return a.jwtService.ValidateAndConsumeState(token)
|
||||
}
|
||||
|
||||
// GenerateSessionToken generates a JWT session token for the user
|
||||
func (a *AuthService) GenerateSessionToken(userInfo *UserInfo) (string, error) {
|
||||
return a.jwtService.GenerateToken(userInfo, a.config.OIDC.SessionMaxAge)
|
||||
}
|
||||
|
||||
// ValidateSessionToken validates a JWT session token and returns user info
|
||||
func (a *AuthService) ValidateSessionToken(tokenString string) (*UserInfo, error) {
|
||||
claims, err := a.jwtService.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserInfo{
|
||||
Username: claims.Username,
|
||||
Email: claims.Email,
|
||||
Name: claims.Name,
|
||||
Roles: claims.Roles,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type JWTService struct {
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey *rsa.PublicKey
|
||||
stateStore *StateStore
|
||||
}
|
||||
|
||||
type StateStore struct {
|
||||
mu sync.RWMutex
|
||||
states map[string]StateData
|
||||
}
|
||||
|
||||
type StateData struct {
|
||||
Created time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type SessionClaims struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Roles []string `json:"roles"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewJWTService() (*JWTService, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate RSA key: %w", err)
|
||||
}
|
||||
|
||||
return &JWTService{
|
||||
privateKey: privateKey,
|
||||
publicKey: &privateKey.PublicKey,
|
||||
stateStore: &StateStore{
|
||||
states: make(map[string]StateData),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (j *JWTService) GenerateStateToken() (string, error) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate state token: %w", err)
|
||||
}
|
||||
|
||||
token := base64.URLEncoding.EncodeToString(tokenBytes)
|
||||
|
||||
j.stateStore.mu.Lock()
|
||||
defer j.stateStore.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
j.stateStore.states[token] = StateData{
|
||||
Created: now,
|
||||
ExpiresAt: now.Add(10 * time.Minute),
|
||||
}
|
||||
|
||||
go j.cleanupExpiredStates()
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (j *JWTService) ValidateAndConsumeState(token string) bool {
|
||||
j.stateStore.mu.Lock()
|
||||
defer j.stateStore.mu.Unlock()
|
||||
|
||||
state, exists := j.stateStore.states[token]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
if time.Now().After(state.ExpiresAt) {
|
||||
delete(j.stateStore.states, token)
|
||||
return false
|
||||
}
|
||||
|
||||
delete(j.stateStore.states, token)
|
||||
return true
|
||||
}
|
||||
|
||||
func (j *JWTService) cleanupExpiredStates() {
|
||||
j.stateStore.mu.Lock()
|
||||
defer j.stateStore.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for token, state := range j.stateStore.states {
|
||||
if now.After(state.ExpiresAt) {
|
||||
delete(j.stateStore.states, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (string, error) {
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second)
|
||||
|
||||
claims := SessionClaims{
|
||||
Username: userInfo.Username,
|
||||
Email: userInfo.Email,
|
||||
Name: userInfo.Name,
|
||||
Roles: userInfo.Roles,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
tokenString, err := token.SignedString(j.privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func (j *JWTService) ValidateToken(tokenString string) (*SessionClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &SessionClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return j.publicKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*SessionClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
func (j *JWTService) GetPublicKeyPEM() (string, error) {
|
||||
pubKeyBytes, err := x509.MarshalPKIXPublicKey(j.publicKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal public key: %w", err)
|
||||
}
|
||||
|
||||
pubKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PUBLIC KEY",
|
||||
Bytes: pubKeyBytes,
|
||||
})
|
||||
|
||||
return string(pubKeyPEM), nil
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Garage GarageConfig `mapstructure:"garage"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
CORS CORSConfig `mapstructure:"cors"`
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
}
|
||||
|
||||
// ServerConfig contains server-related configuration
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Environment string `mapstructure:"environment"`
|
||||
FrontendPath string `mapstructure:"frontend_path"` // Path to frontend dist directory
|
||||
}
|
||||
|
||||
// GarageConfig contains Garage S3 connection settings
|
||||
type GarageConfig struct {
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
Region string `mapstructure:"region"`
|
||||
UseSSL bool `mapstructure:"use_ssl"`
|
||||
ForcePathStyle bool `mapstructure:"force_path_style"`
|
||||
AdminEndpoint string `mapstructure:"admin_endpoint"`
|
||||
AdminToken string `mapstructure:"admin_token"`
|
||||
}
|
||||
|
||||
// AuthConfig contains authentication configuration
|
||||
type AuthConfig struct {
|
||||
Mode string `mapstructure:"mode"` // "none", "basic", or "oidc"
|
||||
Basic BasicAuthConfig `mapstructure:"basic"`
|
||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
||||
}
|
||||
|
||||
// BasicAuthConfig contains basic authentication settings
|
||||
type BasicAuthConfig struct {
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
}
|
||||
|
||||
// OIDCConfig contains OIDC authentication settings
|
||||
type OIDCConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
ProviderName string `mapstructure:"provider_name"`
|
||||
ClientID string `mapstructure:"client_id"`
|
||||
ClientSecret string `mapstructure:"client_secret"`
|
||||
Scopes []string `mapstructure:"scopes"`
|
||||
IssuerURL string `mapstructure:"issuer_url"`
|
||||
AuthURL string `mapstructure:"auth_url"`
|
||||
TokenURL string `mapstructure:"token_url"`
|
||||
UserinfoURL string `mapstructure:"userinfo_url"`
|
||||
SkipIssuerCheck bool `mapstructure:"skip_issuer_check"`
|
||||
SkipExpiryCheck bool `mapstructure:"skip_expiry_check"`
|
||||
EmailAttribute string `mapstructure:"email_attribute"`
|
||||
UsernameAttribute string `mapstructure:"username_attribute"`
|
||||
NameAttribute string `mapstructure:"name_attribute"`
|
||||
RoleAttributePath string `mapstructure:"role_attribute_path"`
|
||||
AdminRole string `mapstructure:"admin_role"`
|
||||
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
|
||||
SessionMaxAge int `mapstructure:"session_max_age"`
|
||||
CookieName string `mapstructure:"cookie_name"`
|
||||
CookieSecure bool `mapstructure:"cookie_secure"`
|
||||
CookieHTTPOnly bool `mapstructure:"cookie_http_only"`
|
||||
CookieSameSite string `mapstructure:"cookie_same_site"`
|
||||
}
|
||||
|
||||
// CORSConfig contains CORS settings for frontend communication
|
||||
type CORSConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
AllowedOrigins []string `mapstructure:"allowed_origins"`
|
||||
AllowedMethods []string `mapstructure:"allowed_methods"`
|
||||
AllowedHeaders []string `mapstructure:"allowed_headers"`
|
||||
AllowCredentials bool `mapstructure:"allow_credentials"`
|
||||
MaxAge int `mapstructure:"max_age"`
|
||||
}
|
||||
|
||||
// LoggingConfig contains logging configuration
|
||||
type LoggingConfig struct {
|
||||
Level string `mapstructure:"level"`
|
||||
Format string `mapstructure:"format"`
|
||||
}
|
||||
|
||||
// Load reads the configuration from the specified file
|
||||
func Load(configPath string) (*Config, error) {
|
||||
// Set default config file name if not specified
|
||||
if configPath == "" {
|
||||
configPath = "config.yaml"
|
||||
}
|
||||
|
||||
// Configure viper to read the config file
|
||||
viper.SetConfigFile(configPath)
|
||||
viper.SetConfigType("yaml")
|
||||
|
||||
// Allow environment variables to override config values
|
||||
// Environment variables take precedence over config file
|
||||
viper.AutomaticEnv()
|
||||
viper.SetEnvPrefix("GARAGE_UI")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
|
||||
// Bind environment variables to config keys
|
||||
// This ensures env vars override config file values
|
||||
bindEnvVars()
|
||||
|
||||
// Read the config file (optional - will use defaults and env vars if not found)
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("error reading config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Unmarshal the config into the Config struct
|
||||
var cfg Config
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling config: %w", err)
|
||||
}
|
||||
|
||||
// Validate the configuration
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// bindEnvVars binds all environment variables to their corresponding config keys
|
||||
func bindEnvVars() {
|
||||
// Server config
|
||||
viper.BindEnv("server.host", "GARAGE_UI_SERVER_HOST")
|
||||
viper.BindEnv("server.port", "GARAGE_UI_SERVER_PORT")
|
||||
viper.BindEnv("server.environment", "GARAGE_UI_SERVER_ENVIRONMENT")
|
||||
viper.BindEnv("server.frontend_path", "GARAGE_UI_SERVER_FRONTEND_PATH")
|
||||
|
||||
// Garage config
|
||||
viper.BindEnv("garage.endpoint", "GARAGE_UI_GARAGE_ENDPOINT")
|
||||
viper.BindEnv("garage.region", "GARAGE_UI_GARAGE_REGION")
|
||||
viper.BindEnv("garage.use_ssl", "GARAGE_UI_GARAGE_USE_SSL")
|
||||
viper.BindEnv("garage.force_path_style", "GARAGE_UI_GARAGE_FORCE_PATH_STYLE")
|
||||
viper.BindEnv("garage.admin_endpoint", "GARAGE_UI_GARAGE_ADMIN_ENDPOINT")
|
||||
viper.BindEnv("garage.admin_token", "GARAGE_UI_GARAGE_ADMIN_TOKEN")
|
||||
|
||||
// Auth config
|
||||
viper.BindEnv("auth.mode", "GARAGE_UI_AUTH_MODE")
|
||||
viper.BindEnv("auth.basic.username", "GARAGE_UI_AUTH_BASIC_USERNAME")
|
||||
viper.BindEnv("auth.basic.password", "GARAGE_UI_AUTH_BASIC_PASSWORD")
|
||||
|
||||
// OIDC config
|
||||
viper.BindEnv("auth.oidc.enabled", "GARAGE_UI_AUTH_OIDC_ENABLED")
|
||||
viper.BindEnv("auth.oidc.provider_name", "GARAGE_UI_AUTH_OIDC_PROVIDER_NAME")
|
||||
viper.BindEnv("auth.oidc.client_id", "GARAGE_UI_AUTH_OIDC_CLIENT_ID")
|
||||
viper.BindEnv("auth.oidc.client_secret", "GARAGE_UI_AUTH_OIDC_CLIENT_SECRET")
|
||||
viper.BindEnv("auth.oidc.scopes", "GARAGE_UI_AUTH_OIDC_SCOPES")
|
||||
viper.BindEnv("auth.oidc.issuer_url", "GARAGE_UI_AUTH_OIDC_ISSUER_URL")
|
||||
viper.BindEnv("auth.oidc.auth_url", "GARAGE_UI_AUTH_OIDC_AUTH_URL")
|
||||
viper.BindEnv("auth.oidc.token_url", "GARAGE_UI_AUTH_OIDC_TOKEN_URL")
|
||||
viper.BindEnv("auth.oidc.userinfo_url", "GARAGE_UI_AUTH_OIDC_USERINFO_URL")
|
||||
viper.BindEnv("auth.oidc.skip_issuer_check", "GARAGE_UI_AUTH_OIDC_SKIP_ISSUER_CHECK")
|
||||
viper.BindEnv("auth.oidc.skip_expiry_check", "GARAGE_UI_AUTH_OIDC_SKIP_EXPIRY_CHECK")
|
||||
viper.BindEnv("auth.oidc.email_attribute", "GARAGE_UI_AUTH_OIDC_EMAIL_ATTRIBUTE")
|
||||
viper.BindEnv("auth.oidc.username_attribute", "GARAGE_UI_AUTH_OIDC_USERNAME_ATTRIBUTE")
|
||||
viper.BindEnv("auth.oidc.name_attribute", "GARAGE_UI_AUTH_OIDC_NAME_ATTRIBUTE")
|
||||
viper.BindEnv("auth.oidc.role_attribute_path", "GARAGE_UI_AUTH_OIDC_ROLE_ATTRIBUTE_PATH")
|
||||
viper.BindEnv("auth.oidc.admin_role", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLE")
|
||||
viper.BindEnv("auth.oidc.tls_skip_verify", "GARAGE_UI_AUTH_OIDC_TLS_SKIP_VERIFY")
|
||||
viper.BindEnv("auth.oidc.session_max_age", "GARAGE_UI_AUTH_OIDC_SESSION_MAX_AGE")
|
||||
viper.BindEnv("auth.oidc.cookie_name", "GARAGE_UI_AUTH_OIDC_COOKIE_NAME")
|
||||
viper.BindEnv("auth.oidc.cookie_secure", "GARAGE_UI_AUTH_OIDC_COOKIE_SECURE")
|
||||
viper.BindEnv("auth.oidc.cookie_http_only", "GARAGE_UI_AUTH_OIDC_COOKIE_HTTP_ONLY")
|
||||
viper.BindEnv("auth.oidc.cookie_same_site", "GARAGE_UI_AUTH_OIDC_COOKIE_SAME_SITE")
|
||||
|
||||
// CORS config
|
||||
viper.BindEnv("cors.enabled", "GARAGE_UI_CORS_ENABLED")
|
||||
viper.BindEnv("cors.allowed_origins", "GARAGE_UI_CORS_ALLOWED_ORIGINS")
|
||||
viper.BindEnv("cors.allowed_methods", "GARAGE_UI_CORS_ALLOWED_METHODS")
|
||||
viper.BindEnv("cors.allowed_headers", "GARAGE_UI_CORS_ALLOWED_HEADERS")
|
||||
viper.BindEnv("cors.allow_credentials", "GARAGE_UI_CORS_ALLOW_CREDENTIALS")
|
||||
viper.BindEnv("cors.max_age", "GARAGE_UI_CORS_MAX_AGE")
|
||||
|
||||
// Logging config
|
||||
viper.BindEnv("logging.level", "GARAGE_UI_LOGGING_LEVEL")
|
||||
viper.BindEnv("logging.format", "GARAGE_UI_LOGGING_FORMAT")
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid
|
||||
func (c *Config) Validate() error {
|
||||
// Validate server config
|
||||
if c.Server.Port <= 0 || c.Server.Port > 65535 {
|
||||
return fmt.Errorf("invalid server port: %d", c.Server.Port)
|
||||
}
|
||||
|
||||
// Validate Garage config
|
||||
if c.Garage.Endpoint == "" {
|
||||
return fmt.Errorf("garage endpoint is required")
|
||||
}
|
||||
if c.Garage.AdminEndpoint == "" {
|
||||
return fmt.Errorf("garage admin_endpoint is required")
|
||||
}
|
||||
if c.Garage.AdminToken == "" {
|
||||
return fmt.Errorf("garage admin_token is required")
|
||||
}
|
||||
|
||||
// Validate auth mode
|
||||
if c.Auth.Mode != "none" && c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" {
|
||||
return fmt.Errorf("auth mode must be 'none', 'basic', or 'oidc', got: %s", c.Auth.Mode)
|
||||
}
|
||||
|
||||
// Validate basic auth if enabled
|
||||
if c.Auth.Mode == "basic" {
|
||||
if c.Auth.Basic.Username == "" || c.Auth.Basic.Password == "" {
|
||||
return fmt.Errorf("basic auth username and password are required when auth mode is 'basic'")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate OIDC config if enabled
|
||||
if c.Auth.Mode == "oidc" {
|
||||
if c.Auth.OIDC.ClientID == "" {
|
||||
return fmt.Errorf("oidc client_id is required when auth mode is 'oidc'")
|
||||
}
|
||||
if c.Auth.OIDC.IssuerURL == "" {
|
||||
return fmt.Errorf("oidc issuer_url is required when auth mode is 'oidc'")
|
||||
}
|
||||
if len(c.Auth.OIDC.Scopes) == 0 {
|
||||
return fmt.Errorf("oidc scopes are required when auth mode is 'oidc'")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAddress returns the full server address (host:port)
|
||||
func (c *Config) GetAddress() string {
|
||||
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
|
||||
}
|
||||
|
||||
// IsDevelopment returns true if running in development mode
|
||||
func (c *Config) IsDevelopment() bool {
|
||||
return c.Server.Environment == "development"
|
||||
}
|
||||
|
||||
// IsProduction returns true if running in production mode
|
||||
func (c *Config) IsProduction() bool {
|
||||
return c.Server.Environment == "production"
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// BucketHandler handles bucket-related operations
|
||||
type BucketHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
s3Service *services.S3Service
|
||||
}
|
||||
|
||||
// NewBucketHandler creates a new bucket handler
|
||||
func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler {
|
||||
return &BucketHandler{
|
||||
adminService: adminService,
|
||||
s3Service: s3Service,
|
||||
}
|
||||
}
|
||||
|
||||
// ListBuckets lists all buckets
|
||||
//
|
||||
// @Summary List all buckets
|
||||
// @Description Retrieves a list of all buckets in the Garage storage system with object count and size
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=models.BucketListResponse} "Successfully retrieved list of buckets"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list buckets"
|
||||
// @Router /api/v1/buckets [get]
|
||||
func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// List all buckets from Garage Admin API
|
||||
adminBuckets, err := h.adminService.ListBuckets(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list buckets: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert admin bucket response to BucketInfo
|
||||
buckets := make([]models.BucketInfo, 0, len(adminBuckets))
|
||||
for _, adminBucket := range adminBuckets {
|
||||
// Get the bucket name from global aliases
|
||||
var bucketName string
|
||||
if len(adminBucket.GlobalAliases) > 0 {
|
||||
bucketName = adminBucket.GlobalAliases[0]
|
||||
} else {
|
||||
// Skip buckets without global aliases
|
||||
continue
|
||||
}
|
||||
|
||||
bucketInfo := models.BucketInfo{
|
||||
Name: bucketName,
|
||||
CreationDate: adminBucket.Created,
|
||||
Region: "", // Garage doesn't have regions
|
||||
}
|
||||
|
||||
// Try to get bucket statistics (object count and size)
|
||||
// This is done asynchronously to avoid blocking the response
|
||||
// If it fails, we still return the bucket info without stats
|
||||
stats, err := h.s3Service.GetBucketStatistics(ctx, bucketName)
|
||||
if err == nil && stats != nil {
|
||||
bucketInfo.ObjectCount = &stats.ObjectCount
|
||||
bucketInfo.Size = &stats.TotalSize
|
||||
}
|
||||
|
||||
buckets = append(buckets, bucketInfo)
|
||||
}
|
||||
|
||||
response := models.BucketListResponse{
|
||||
Buckets: buckets,
|
||||
Count: len(buckets),
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// CreateBucket creates a new bucket
|
||||
//
|
||||
// @Summary Create a new bucket
|
||||
// @Description Creates a new bucket in the Garage storage system
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param payload body models.CreateBucketRequest true "Bucket creation payload"
|
||||
// @Success 201 {object} models.APIResponse{data=object{bucket=string,message=string}} "Bucket created successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body or bucket name is required"
|
||||
// @Failure 409 {object} models.APIResponse{error=models.APIError} "Bucket already exists"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create bucket"
|
||||
// @Router /api/v1/buckets [post]
|
||||
func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Parse request body
|
||||
var req models.CreateBucketRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate bucket name
|
||||
if req.Name == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if bucket already exists
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, req.Name)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if bucketInfo != nil {
|
||||
return c.Status(fiber.StatusConflict).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"),
|
||||
)
|
||||
}
|
||||
|
||||
// Create the bucket
|
||||
createBucketReq := models.CreateBucketAdminRequest{
|
||||
GlobalAlias: &req.Name,
|
||||
}
|
||||
if bucketInfo, err = h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Return success response
|
||||
response := map[string]interface{}{
|
||||
"bucket": req.Name,
|
||||
"message": "Bucket created successfully",
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// DeleteBucket deletes a bucket
|
||||
//
|
||||
// @Summary Delete a bucket
|
||||
// @Description Deletes an existing bucket from the Garage storage system. The bucket must be empty before deletion.
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket to delete"
|
||||
// @Success 200 {object} models.APIResponse{data=object{bucket=string,message=string}} "Bucket deleted successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name is required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket does not exist"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete bucket"
|
||||
// @Router /api/v1/buckets/{name} [delete]
|
||||
func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("name")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if bucket already exists
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if bucketInfo == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
|
||||
)
|
||||
}
|
||||
|
||||
// Delete the bucket
|
||||
if err := h.adminService.DeleteBucket(ctx, bucketInfo.ID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete bucket: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Return success response
|
||||
response := map[string]interface{}{
|
||||
"bucket": bucketName,
|
||||
"message": "Bucket deleted successfully",
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// GetBucketInfo returns information about a specific bucket
|
||||
//
|
||||
// @Summary Get bucket information
|
||||
// @Description Retrieves detailed information about a specific bucket including creation date and region
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket to retrieve information for"
|
||||
// @Success 200 {object} models.APIResponse{data=models.BucketInfo} "Successfully retrieved bucket information"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name is required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket does not exist"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to retrieve bucket information"
|
||||
// @Router /api/v1/buckets/{name} [get]
|
||||
func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("name")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if bucket already exists
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if bucketInfo == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(bucketInfo))
|
||||
}
|
||||
|
||||
// GrantBucketPermission grants permissions for an access key on a bucket
|
||||
//
|
||||
// @Summary Grant bucket permissions
|
||||
// @Description Grants read/write/owner permissions for an access key on a specific bucket
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket"
|
||||
// @Param request body models.GrantBucketPermissionRequest true "Permission grant request"
|
||||
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Permissions granted successfully"
|
||||
// @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 grant permissions"
|
||||
// @Router /api/v1/buckets/{name}/permissions [post]
|
||||
func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("name")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req models.GrantBucketPermissionRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate access key ID
|
||||
if req.AccessKeyID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Access key ID is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get bucket info to retrieve bucket ID
|
||||
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"),
|
||||
)
|
||||
}
|
||||
|
||||
// Build the permission request for Garage Admin API
|
||||
permRequest := models.BucketKeyPermRequest{
|
||||
BucketID: bucketInfo.ID,
|
||||
AccessKeyID: req.AccessKeyID,
|
||||
Permissions: models.BucketKeyPermission{
|
||||
Read: req.Permissions.Read,
|
||||
Write: req.Permissions.Write,
|
||||
Owner: req.Permissions.Owner,
|
||||
},
|
||||
}
|
||||
|
||||
// Grant permissions using Garage Admin API
|
||||
result, err := h.adminService.AllowBucketKey(ctx, permRequest)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ClusterHandler handles cluster management operations
|
||||
type ClusterHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
}
|
||||
|
||||
// NewClusterHandler creates a new cluster handler
|
||||
func NewClusterHandler(adminService *services.GarageAdminService) *ClusterHandler {
|
||||
return &ClusterHandler{
|
||||
adminService: adminService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetHealth returns the health status of the cluster
|
||||
//
|
||||
// @Summary Get cluster health
|
||||
// @Description Retrieves the overall health status of the Garage storage cluster
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster health"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster health"
|
||||
// @Router /api/v1/cluster/health [get]
|
||||
func (h *ClusterHandler) GetHealth(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
health, err := h.adminService.GetClusterHealth(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster health: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(health))
|
||||
}
|
||||
|
||||
// GetStatus returns the status of the cluster
|
||||
//
|
||||
// @Summary Get cluster status
|
||||
// @Description Retrieves the current status of the Garage storage cluster
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster status"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster status"
|
||||
// @Router /api/v1/cluster/status [get]
|
||||
func (h *ClusterHandler) GetStatus(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
status, err := h.adminService.GetClusterStatus(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster status: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(status))
|
||||
}
|
||||
|
||||
// GetStatistics returns global cluster statistics
|
||||
// GET /api/v1/cluster/statistics
|
||||
func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
stats, err := h.adminService.GetClusterStatistics(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster statistics: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(stats))
|
||||
}
|
||||
|
||||
// GetNodeInfo returns information about a specific node
|
||||
//
|
||||
// @Summary Get node information
|
||||
// @Description Retrieves detailed information about a specific node in the Garage storage cluster
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param node_id path string true "ID of the node to retrieve information for"
|
||||
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node information"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node information"
|
||||
// @Router /api/v1/cluster/nodes/{node_id} [get]
|
||||
func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
nodeID := c.Params("node_id")
|
||||
|
||||
if nodeID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
|
||||
)
|
||||
}
|
||||
|
||||
info, err := h.adminService.GetNodeInfo(ctx, nodeID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node info: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(info))
|
||||
}
|
||||
|
||||
// GetNodeStatistics returns statistics for a specific node
|
||||
//
|
||||
// @Summary Get node statistics
|
||||
// @Description Retrieves performance statistics and metrics for a specific node in the Garage storage cluster
|
||||
// @Tags Cluster
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param node_id path string true "ID of the node to retrieve statistics for"
|
||||
// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node statistics"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node statistics"
|
||||
// @Router /api/v1/cluster/nodes/{node_id}/statistics [get]
|
||||
func (h *ClusterHandler) GetNodeStatistics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
nodeID := c.Params("node_id")
|
||||
|
||||
if nodeID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
|
||||
)
|
||||
}
|
||||
|
||||
stats, err := h.adminService.GetNodeStatistics(ctx, nodeID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node statistics: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(stats))
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// HealthHandler handles health check requests
|
||||
type HealthHandler struct {
|
||||
version string
|
||||
}
|
||||
|
||||
// NewHealthHandler creates a new health check handler
|
||||
func NewHealthHandler(version string) *HealthHandler {
|
||||
return &HealthHandler{
|
||||
version: version,
|
||||
}
|
||||
}
|
||||
|
||||
// Check returns the health status of the service
|
||||
//
|
||||
// @Summary Health check
|
||||
// @Description Returns the health status of the API service along with version information
|
||||
// @Tags Health
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=models.HealthResponse} "Service is healthy"
|
||||
// @Router /api/v1/health [get]
|
||||
func (h *HealthHandler) Check(c fiber.Ctx) error {
|
||||
response := models.HealthResponse{
|
||||
Status: "healthy",
|
||||
Timestamp: time.Now(),
|
||||
Version: h.version,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// MonitoringHandler handles monitoring operations
|
||||
type MonitoringHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
s3Service *services.S3Service
|
||||
}
|
||||
|
||||
// NewMonitoringHandler creates a new monitoring handler
|
||||
func NewMonitoringHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *MonitoringHandler {
|
||||
return &MonitoringHandler{
|
||||
adminService: adminService,
|
||||
s3Service: s3Service,
|
||||
}
|
||||
}
|
||||
|
||||
// GetMetrics retrieves system metrics from the Admin API
|
||||
//
|
||||
// @Summary Get system metrics
|
||||
// @Description Retrieves system metrics from the Garage Admin API for monitoring purposes
|
||||
// @Tags Monitoring
|
||||
// @Accept json
|
||||
// @Produce text/plain
|
||||
// @Success 200 {string} string "System metrics in plain text format"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to retrieve metrics"
|
||||
// @Router /api/v1/monitoring/metrics [get]
|
||||
func (h *MonitoringHandler) GetMetrics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
metrics, err := h.adminService.GetMetrics(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get metrics: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Return metrics as plain text
|
||||
c.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
return c.SendString(metrics)
|
||||
}
|
||||
|
||||
// CheckAdminHealth checks if the Admin API is reachable
|
||||
//
|
||||
// @Summary Check Admin API health
|
||||
// @Description Performs a health check on the Garage Admin API to verify connectivity and availability
|
||||
// @Tags Monitoring
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=object{status=string,message=string}} "Admin API is healthy"
|
||||
// @Failure 503 {object} models.APIResponse{error=models.APIError} "Admin API health check failed"
|
||||
// @Router /api/v1/monitoring/admin-health [get]
|
||||
func (h *MonitoringHandler) CheckAdminHealth(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
err := h.adminService.HealthCheck(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Admin API health check failed: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"message": "Admin API is reachable",
|
||||
}))
|
||||
}
|
||||
|
||||
// GetDashboardMetrics retrieves aggregated dashboard metrics
|
||||
//
|
||||
// @Summary Get dashboard metrics
|
||||
// @Description Retrieves aggregated metrics for the dashboard including storage, buckets, and request metrics
|
||||
// @Tags Monitoring
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=models.DashboardMetrics} "Successfully retrieved dashboard metrics"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get dashboard metrics"
|
||||
// @Router /api/v1/monitoring/dashboard [get]
|
||||
func (h *MonitoringHandler) GetDashboardMetrics(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket list
|
||||
buckets, err := h.adminService.ListBuckets(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get buckets: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate aggregated metrics
|
||||
var totalSize int64
|
||||
var totalObjects int64
|
||||
usageByBucket := make([]models.BucketUsage, 0)
|
||||
|
||||
for _, bucket := range buckets {
|
||||
// Get bucket info to calculate size and object count
|
||||
bucketInfo, err := h.adminService.GetBucketInfo(ctx, bucket.ID)
|
||||
if err != nil {
|
||||
continue // Skip buckets we can't access
|
||||
}
|
||||
|
||||
// Get size and object count from bucket info
|
||||
bucketSize := bucketInfo.Bytes
|
||||
objectCount := bucketInfo.Objects
|
||||
|
||||
totalSize += bucketSize
|
||||
totalObjects += objectCount
|
||||
|
||||
// Get bucket name from aliases
|
||||
bucketName := bucket.ID
|
||||
if len(bucket.LocalAliases) > 0 {
|
||||
bucketName = bucket.LocalAliases[0].Alias
|
||||
} else if len(bucket.GlobalAliases) > 0 {
|
||||
bucketName = bucket.GlobalAliases[0]
|
||||
}
|
||||
|
||||
usageByBucket = append(usageByBucket, models.BucketUsage{
|
||||
BucketName: bucketName,
|
||||
Size: bucketSize,
|
||||
ObjectCount: objectCount,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
for i := range usageByBucket {
|
||||
if totalSize > 0 {
|
||||
usageByBucket[i].Percentage = float64(usageByBucket[i].Size) / float64(totalSize) * 100
|
||||
}
|
||||
}
|
||||
|
||||
dashboardMetrics := models.DashboardMetrics{
|
||||
TotalSize: totalSize,
|
||||
ObjectCount: totalObjects,
|
||||
BucketCount: len(buckets),
|
||||
UsageByBucket: usageByBucket,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(dashboardMetrics))
|
||||
}
|
||||
@@ -1,541 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ObjectHandler handles object-related operations
|
||||
type ObjectHandler struct {
|
||||
s3Service *services.S3Service
|
||||
}
|
||||
|
||||
// NewObjectHandler creates a new object handler
|
||||
func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler {
|
||||
return &ObjectHandler{
|
||||
s3Service: s3Service,
|
||||
}
|
||||
}
|
||||
|
||||
// ListObjects lists objects in a bucket with optional filtering and pagination
|
||||
//
|
||||
// @Summary List objects in a bucket
|
||||
// @Description Retrieves a list of objects and prefixes (folders) stored in the specified bucket, with optional filtering by prefix, pagination support, and max keys
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket to list objects from"
|
||||
// @Param prefix query string false "Filter objects by prefix"
|
||||
// @Param max_keys query int false "Maximum number of objects to return (default: 100)"
|
||||
// @Param continuation_token query string false "Token for pagination to retrieve next page of results"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ObjectListResponse} "Successfully retrieved list of objects and prefixes"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list objects"
|
||||
// @Router /api/v1/buckets/{bucket}/objects [get]
|
||||
func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("bucket")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get query parameters for filtering and pagination
|
||||
prefix := c.Query("prefix", "")
|
||||
continuationToken := c.Query("continuation_token", "")
|
||||
|
||||
maxKeysStr := c.Query("max_keys", "100")
|
||||
maxKeys, err := strconv.Atoi(maxKeysStr)
|
||||
if err != nil || maxKeys <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid max_keys parameter"),
|
||||
)
|
||||
}
|
||||
|
||||
// List objects in the bucket
|
||||
objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys, continuationToken)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list objects: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(objects))
|
||||
}
|
||||
|
||||
// UploadObject uploads an object to a bucket
|
||||
//
|
||||
// @Summary Upload object to bucket
|
||||
// @Description Uploads an object to the specified bucket using multipart/form-data
|
||||
// @Tags Objects
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket to upload the object to"
|
||||
// @Param file formData file true "File to upload"
|
||||
// @Param key formData string false "Object key (path in bucket). If not provided, the filename will be used"
|
||||
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object"
|
||||
// @Router /api/v1/buckets/{bucket}/objects [post]
|
||||
func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("bucket")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get file from multipart form
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "File is required: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Get object key (path in bucket)
|
||||
key := c.FormValue("key")
|
||||
if key == "" {
|
||||
// Use filename as key if not provided
|
||||
key = file.Filename
|
||||
}
|
||||
|
||||
// Open the uploaded file
|
||||
fileHandle, err := file.Open()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open uploaded file: "+err.Error()),
|
||||
)
|
||||
}
|
||||
defer fileHandle.Close()
|
||||
|
||||
// Get content type
|
||||
contentType := file.Header.Get("Content-Type")
|
||||
|
||||
// Upload to Garage
|
||||
uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, fileHandle, contentType)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
|
||||
}
|
||||
|
||||
// GetObject retrieves an object from a bucket
|
||||
//
|
||||
// @Summary Get object from bucket
|
||||
// @Description Retrieves an object stored in the specified bucket
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce application/octet-stream
|
||||
// @Param bucket path string true "Name of the bucket containing the object"
|
||||
// @Param key path string true "Key (path) of the object"
|
||||
// @Param download query bool false "Set to true to download the object as an attachment"
|
||||
// @Success 200 {file} binary "Successfully retrieved the object"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key} [get]
|
||||
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get object from Garage
|
||||
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
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("ETag", objectInfo.ETag)
|
||||
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
|
||||
|
||||
// Check if client wants to download or view inline
|
||||
if c.Query("download") == "true" {
|
||||
c.Set("Content-Disposition", "attachment; filename=\""+key+"\"")
|
||||
}
|
||||
|
||||
// Stream the object body to the client
|
||||
return c.SendStream(body)
|
||||
}
|
||||
|
||||
// DeleteObject deletes an object from a bucket
|
||||
//
|
||||
// @Summary Delete object from bucket
|
||||
// @Description Deletes an object stored in the specified bucket
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the object"
|
||||
// @Param key path string true "Key (path) of the object"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteResponse} "Successfully deleted the object"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete object"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key} [delete]
|
||||
func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if object exists
|
||||
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"),
|
||||
)
|
||||
}
|
||||
|
||||
// Delete the object
|
||||
if err := h.s3Service.DeleteObject(ctx, bucketName, key); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete object: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Return success response
|
||||
response := models.ObjectDeleteResponse{
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
Deleted: true,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// GetObjectMetadata returns metadata for an object without downloading it
|
||||
//
|
||||
// @Summary Get object metadata
|
||||
// @Description Retrieves metadata information about an object without downloading the actual content
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the object"
|
||||
// @Param key path string true "Key (path) of the object"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ObjectInfo} "Successfully retrieved object metadata"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key}/metadata [get]
|
||||
func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get object metadata
|
||||
metadata, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(metadata))
|
||||
}
|
||||
|
||||
// GetPresignedURL generates a pre-signed URL for accessing an object
|
||||
//
|
||||
// @Summary Get pre-signed URL for object
|
||||
// @Description Generates a pre-signed URL that allows temporary access to the specified object
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the object"
|
||||
// @Param key path string true "Key (path) of the object"
|
||||
// @Param expires_in query int false "Expiration time in seconds for the pre-signed URL (default: 3600 seconds)"
|
||||
// @Success 200 {object} models.APIResponse{data=models.PresignedURLResponse} "Successfully generated pre-signed URL"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to generate pre-signed URL"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/{key}/presigned-url [get]
|
||||
func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name and object key from URL parameters
|
||||
bucketName := c.Params("bucket")
|
||||
key := c.Params("key")
|
||||
|
||||
if bucketName == "" || key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get expiration time from query parameter (default: 1 hour)
|
||||
expiresInStr := c.Query("expires_in", "3600")
|
||||
expiresIn, err := strconv.ParseInt(expiresInStr, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate expiration time (1 second to 7 days)
|
||||
if expiresIn <= 0 || expiresIn > 604800 { // Max 7 days
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time (must be between 1 and 604800 seconds)"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if object exists
|
||||
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"),
|
||||
)
|
||||
}
|
||||
|
||||
// Generate pre-signed URL
|
||||
url, err := h.s3Service.GetPresignedURL(ctx, bucketName, key, time.Duration(expiresIn)*time.Second)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to generate pre-signed URL: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
response := models.PresignedURLResponse{
|
||||
URL: url,
|
||||
ExpiresIn: expiresIn,
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// DeleteMultipleObjects deletes multiple objects from a bucket
|
||||
//
|
||||
// @Summary Delete multiple objects from bucket
|
||||
// @Description Deletes multiple objects stored in the specified bucket
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket containing the objects"
|
||||
// @Param request body object{keys=[]string,prefix=string} true "List of object keys to delete and optional prefix for path context"
|
||||
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteMultipleResponse} "Successfully deleted the objects"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete objects"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/delete-multiple [post]
|
||||
func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("bucket")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse request body to get keys and optional prefix
|
||||
var req struct {
|
||||
Keys []string `json:"keys"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
}
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if len(req.Keys) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Delete multiple objects
|
||||
if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
response := models.ObjectDeleteMultipleResponse{
|
||||
Bucket: bucketName,
|
||||
Deleted: len(req.Keys),
|
||||
Keys: req.Keys,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(response))
|
||||
}
|
||||
|
||||
// UploadMultipleObjects uploads multiple objects to a bucket
|
||||
//
|
||||
// @Summary Upload multiple objects to bucket
|
||||
// @Description Uploads multiple objects to the specified bucket using multipart/form-data. Accepts unlimited number of files and handles them in a loop.
|
||||
// @Tags Objects
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket to upload the objects to"
|
||||
// @Param files formData file true "Files to upload (can be multiple)"
|
||||
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadMultipleResponse} "Objects uploaded successfully (including partial failures)"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload objects"
|
||||
// @Router /api/v1/buckets/{bucket}/objects/upload-multiple [post]
|
||||
func (h *ObjectHandler) UploadMultipleObjects(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Get bucket name from URL parameter
|
||||
bucketName := c.Params("bucket")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse multipart form to get all files
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Failed to parse multipart form: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
files := form.File["files"]
|
||||
if len(files) == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "At least one file is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Prepare upload data structure
|
||||
uploadFiles := make([]struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}, len(files))
|
||||
|
||||
// Open all files and prepare for upload
|
||||
for i, fileHeader := range files {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open file "+fileHeader.Filename+": "+err.Error()),
|
||||
)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Use filename as the key
|
||||
key := fileHeader.Filename
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
uploadFiles[i] = struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}{
|
||||
Key: key,
|
||||
Body: file,
|
||||
ContentType: contentType,
|
||||
}
|
||||
}
|
||||
|
||||
// Upload all files using the service method
|
||||
results := h.s3Service.UploadMultipleObjects(ctx, bucketName, uploadFiles)
|
||||
|
||||
// Process results and categorize successes and failures
|
||||
var successFiles []models.ObjectUploadResult
|
||||
var failedFiles []models.ObjectUploadFailedResult
|
||||
successCount := 0
|
||||
failureCount := 0
|
||||
|
||||
for _, result := range results {
|
||||
if result.Success {
|
||||
successCount++
|
||||
successFiles = append(successFiles, models.ObjectUploadResult{
|
||||
Key: result.Key,
|
||||
ETag: result.ETag,
|
||||
Size: result.Size,
|
||||
ContentType: result.ContentType,
|
||||
})
|
||||
} else {
|
||||
failureCount++
|
||||
failedFiles = append(failedFiles, models.ObjectUploadFailedResult{
|
||||
Key: result.Key,
|
||||
Error: result.Error.Error(),
|
||||
ContentType: result.ContentType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
response := models.ObjectUploadMultipleResponse{
|
||||
Bucket: bucketName,
|
||||
TotalFiles: len(files),
|
||||
SuccessCount: successCount,
|
||||
FailureCount: failureCount,
|
||||
SuccessFiles: successFiles,
|
||||
FailedFiles: failedFiles,
|
||||
}
|
||||
|
||||
// Return 201 if all succeeded, 207 (Multi-Status) if partial success, 500 if all failed
|
||||
statusCode := fiber.StatusCreated
|
||||
if failureCount > 0 && successCount > 0 {
|
||||
statusCode = fiber.StatusMultiStatus // 207
|
||||
} else if failureCount > 0 && successCount == 0 {
|
||||
statusCode = fiber.StatusInternalServerError
|
||||
}
|
||||
|
||||
return c.Status(statusCode).JSON(models.SuccessResponse(response))
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// UserHandler handles user/key management operations using Garage Admin API
|
||||
type UserHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new user handler
|
||||
func NewUserHandler(adminService *services.GarageAdminService) *UserHandler {
|
||||
return &UserHandler{
|
||||
adminService: adminService,
|
||||
}
|
||||
}
|
||||
|
||||
// ListUsers lists all users/access keys
|
||||
//
|
||||
// @Summary List all users
|
||||
// @Description Retrieves a list of all users/access keys
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.APIResponse{data=models.UserListResponse} "List of users retrieved successfully"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list users"
|
||||
// @Router /api/v1/users [get]
|
||||
func (h *UserHandler) ListUsers(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
keys, err := h.adminService.ListKeys(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to list users: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert to UserInfo format
|
||||
users := make([]models.UserInfo, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
// Get full key info to retrieve bucket permissions
|
||||
keyInfo, err := h.adminService.GetKeyInfo(ctx, key.ID, false)
|
||||
if err != nil {
|
||||
// If we can't get full info, skip this key or use basic info
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
|
||||
|
||||
// Determine status based on expiration
|
||||
status := "active"
|
||||
if keyInfo.Expired {
|
||||
status = "inactive"
|
||||
}
|
||||
|
||||
users = append(users, models.UserInfo{
|
||||
AccessKeyID: keyInfo.AccessKeyID,
|
||||
Name: keyInfo.Name,
|
||||
CreatedAt: keyInfo.Created,
|
||||
Status: status,
|
||||
BucketPermissions: bucketPermissions,
|
||||
Expiration: keyInfo.Expiration,
|
||||
Expired: keyInfo.Expired,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(models.UserListResponse{
|
||||
Users: users,
|
||||
Count: len(users),
|
||||
}))
|
||||
}
|
||||
|
||||
// convertBucketPermissionsToBucketPermissions converts Garage bucket permissions to frontend BucketPermission format
|
||||
func convertBucketPermissionsToBucketPermissions(buckets []models.KeyBucketInfo) []models.BucketPermission {
|
||||
permissions := make([]models.BucketPermission, 0, len(buckets))
|
||||
|
||||
for _, bucket := range buckets {
|
||||
// Get bucket name from aliases
|
||||
var bucketName string
|
||||
if len(bucket.GlobalAliases) > 0 {
|
||||
bucketName = bucket.GlobalAliases[0]
|
||||
} else if len(bucket.LocalAliases) > 0 {
|
||||
bucketName = bucket.LocalAliases[0]
|
||||
} else {
|
||||
bucketName = bucket.ID
|
||||
}
|
||||
|
||||
// Create bucket permission with simple read/write/owner flags
|
||||
permissions = append(permissions, models.BucketPermission{
|
||||
BucketID: bucket.ID,
|
||||
BucketName: bucketName,
|
||||
Read: bucket.Permissions.Read,
|
||||
Write: bucket.Permissions.Write,
|
||||
Owner: bucket.Permissions.Owner,
|
||||
})
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
|
||||
// CreateUser creates a new user/access key
|
||||
//
|
||||
// @Summary Create a new user
|
||||
// @Description Creates a new user/access key with optional name
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.CreateUserRequest true "User creation request"
|
||||
// @Success 201 {object} models.APIResponse{data=models.UserInfo} "User created successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create user"
|
||||
// @Router /api/v1/users [post]
|
||||
func (h *UserHandler) CreateUser(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
var req models.CreateUserRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Prepare create key request
|
||||
createReq := models.CreateKeyRequest{}
|
||||
if req.Name != "" {
|
||||
createReq.Name = &req.Name
|
||||
}
|
||||
|
||||
// Create the key
|
||||
keyInfo, err := h.adminService.CreateKey(ctx, createReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create user: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
|
||||
|
||||
// Determine status
|
||||
status := "active"
|
||||
if keyInfo.Expired {
|
||||
status = "inactive"
|
||||
}
|
||||
|
||||
// Convert to UserInfo format
|
||||
userInfo := models.UserInfo{
|
||||
AccessKeyID: keyInfo.AccessKeyID,
|
||||
SecretKey: keyInfo.SecretAccessKey,
|
||||
Name: keyInfo.Name,
|
||||
CreatedAt: keyInfo.Created,
|
||||
Status: status,
|
||||
BucketPermissions: bucketPermissions,
|
||||
Expiration: keyInfo.Expiration,
|
||||
Expired: keyInfo.Expired,
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(userInfo))
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user/access key
|
||||
//
|
||||
// @Summary Delete a user
|
||||
// @Description Deletes a specific user/access key
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Param access_key path string true "Access key of the user to delete"
|
||||
// @Success 200 {object} models.APIResponse{data=map[string]interface{}} "User deleted successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete user"
|
||||
// @Router /api/v1/users/{access_key} [delete]
|
||||
func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
accessKey := c.Params("access_key")
|
||||
|
||||
if accessKey == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Delete the key
|
||||
err := h.adminService.DeleteKey(ctx, accessKey)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to delete user: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(map[string]interface{}{
|
||||
"access_key": accessKey,
|
||||
"deleted": true,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetUser retrieves information about a specific user/access key
|
||||
//
|
||||
// @Summary Get user information
|
||||
// @Description Retrieves information about a specific user/access key
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Param access_key path string true "Access key of the user to retrieve"
|
||||
// @Success 200 {object} models.APIResponse{data=models.UserInfo} "User information retrieved successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get user info"
|
||||
// @Router /api/v1/users/{access_key} [get]
|
||||
func (h *UserHandler) GetUser(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
accessKey := c.Params("access_key")
|
||||
|
||||
if accessKey == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get key information (without secret key)
|
||||
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, false)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get user info: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
|
||||
|
||||
// Determine status
|
||||
status := "active"
|
||||
if keyInfo.Expired {
|
||||
status = "inactive"
|
||||
}
|
||||
|
||||
// Convert to UserInfo format
|
||||
userInfo := models.UserInfo{
|
||||
AccessKeyID: keyInfo.AccessKeyID,
|
||||
Name: keyInfo.Name,
|
||||
CreatedAt: keyInfo.Created,
|
||||
Status: status,
|
||||
BucketPermissions: bucketPermissions,
|
||||
Expiration: keyInfo.Expiration,
|
||||
Expired: keyInfo.Expired,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(userInfo))
|
||||
}
|
||||
|
||||
// UpdateUserPermissions updates user permissions
|
||||
//
|
||||
// @Summary Update user permissions
|
||||
// @Description Updates the permissions and settings for a specific user/access key
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param access_key path string true "Access key of the user to update"
|
||||
// @Param request body models.UpdateUserRequest true "User update request with new permissions"
|
||||
// @Success 200 {object} models.APIResponse{data=models.UserInfo} "User updated successfully"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required or invalid request body"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update user"
|
||||
// @Router /api/v1/users/{access_key} [patch]
|
||||
func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
accessKey := c.Params("access_key")
|
||||
|
||||
if accessKey == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
|
||||
)
|
||||
}
|
||||
|
||||
var req models.UpdateUserRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Prepare update request
|
||||
updateReq := models.UpdateKeyRequest{}
|
||||
|
||||
// Handle status change (activate/deactivate)
|
||||
if req.Status != nil {
|
||||
if *req.Status == "inactive" {
|
||||
// Deactivate by setting expiration to the past
|
||||
pastTime := time.Now().Add(-24 * time.Hour)
|
||||
updateReq.Expiration = &pastTime
|
||||
updateReq.NeverExpires = false
|
||||
} else if *req.Status == "active" {
|
||||
// Activate by removing expiration (set to never expire)
|
||||
updateReq.NeverExpires = true
|
||||
}
|
||||
}
|
||||
|
||||
// Handle explicit expiration date setting
|
||||
if req.Expiration != nil && *req.Expiration != "" {
|
||||
expirationTime, err := time.Parse(time.RFC3339, *req.Expiration)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration date format: "+err.Error()),
|
||||
)
|
||||
}
|
||||
updateReq.Expiration = &expirationTime
|
||||
updateReq.NeverExpires = false
|
||||
}
|
||||
|
||||
// Update the key
|
||||
keyInfo, err := h.adminService.UpdateKey(ctx, accessKey, updateReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update user: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets)
|
||||
|
||||
// Determine status
|
||||
status := "active"
|
||||
if keyInfo.Expired {
|
||||
status = "inactive"
|
||||
}
|
||||
|
||||
// Convert to UserInfo format
|
||||
userInfo := models.UserInfo{
|
||||
AccessKeyID: keyInfo.AccessKeyID,
|
||||
Name: keyInfo.Name,
|
||||
CreatedAt: keyInfo.Created,
|
||||
Status: status,
|
||||
BucketPermissions: bucketPermissions,
|
||||
Expiration: keyInfo.Expiration,
|
||||
Expired: keyInfo.Expired,
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(userInfo))
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthMiddleware returns a Fiber middleware for authentication
|
||||
// It handles different auth modes: none, basic, and OIDC
|
||||
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
// If auth mode is "none", allow all requests
|
||||
if cfg.Mode == "none" {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Handle basic authentication
|
||||
if cfg.Mode == "basic" {
|
||||
return handleBasicAuth(c, authService)
|
||||
}
|
||||
|
||||
// Handle OIDC authentication
|
||||
if cfg.Mode == "oidc" {
|
||||
return handleOIDCAuth(c, authService, &cfg.OIDC)
|
||||
}
|
||||
|
||||
// Unknown auth mode - deny access
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid authentication mode"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBasicAuth validates basic authentication credentials
|
||||
func handleBasicAuth(c fiber.Ctx, authService *auth.AuthService) error {
|
||||
// Get Authorization header
|
||||
authHeader := c.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Authorization header required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse basic auth credentials
|
||||
username, password, ok := auth.ParseBasicAuth(authHeader)
|
||||
if !ok {
|
||||
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid Authorization header format"),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate credentials
|
||||
if !authService.ValidateBasicAuth(username, password) {
|
||||
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"),
|
||||
)
|
||||
}
|
||||
|
||||
// Store username in context for later use
|
||||
c.Locals("username", username)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// handleOIDCAuth validates OIDC session/token
|
||||
func handleOIDCAuth(c fiber.Ctx, authService *auth.AuthService, oidcCfg *config.OIDCConfig) error {
|
||||
// Get session cookie
|
||||
sessionCookie := c.Cookies(oidcCfg.CookieName)
|
||||
if sessionCookie == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate JWT session token
|
||||
userInfo, err := authService.ValidateSessionToken(sessionCookie)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid or expired session"),
|
||||
)
|
||||
}
|
||||
|
||||
// Store user info in context for handlers to use
|
||||
c.Locals("userInfo", userInfo)
|
||||
c.Locals("username", userInfo.Username)
|
||||
c.Locals("email", userInfo.Email)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
func RequireAuth(cfg *config.AuthConfig) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
if cfg.Mode == "none" {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"),
|
||||
)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireAdmin(authService *auth.AuthService) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
userInfoInterface := c.Locals("userInfo")
|
||||
if userInfoInterface == nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
|
||||
)
|
||||
}
|
||||
|
||||
userInfo, ok := userInfoInterface.(*auth.UserInfo)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user has admin role
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Admin role required"),
|
||||
)
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// CORSMiddleware creates a CORS middleware from configuration
|
||||
func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
|
||||
// If CORS is disabled, return a no-op middleware
|
||||
if !cfg.Enabled {
|
||||
return func(c fiber.Ctx) error {
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
return func(c fiber.Ctx) error {
|
||||
origin := c.Get("Origin")
|
||||
|
||||
// Check if origin is allowed
|
||||
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins) {
|
||||
// Set CORS headers
|
||||
c.Set("Access-Control-Allow-Origin", origin)
|
||||
|
||||
if cfg.AllowCredentials {
|
||||
c.Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
|
||||
// Set allowed methods
|
||||
if len(cfg.AllowedMethods) > 0 {
|
||||
c.Set("Access-Control-Allow-Methods", strings.Join(cfg.AllowedMethods, ", "))
|
||||
}
|
||||
|
||||
// Set allowed headers
|
||||
if len(cfg.AllowedHeaders) > 0 {
|
||||
c.Set("Access-Control-Allow-Headers", strings.Join(cfg.AllowedHeaders, ", "))
|
||||
}
|
||||
|
||||
// Set max age for preflight cache
|
||||
if cfg.MaxAge > 0 {
|
||||
c.Set("Access-Control-Max-Age", string(rune(cfg.MaxAge)))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle preflight requests
|
||||
if c.Method() == "OPTIONS" {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// isAllowedOrigin checks if an origin is in the allowed list
|
||||
func isAllowedOrigin(origin string, allowedOrigins []string) bool {
|
||||
for _, allowed := range allowedOrigins {
|
||||
if allowed == "*" || allowed == origin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ====================================
|
||||
// Access Key Models
|
||||
// ====================================
|
||||
|
||||
// GarageKeyInfo represents detailed information about a Garage access key
|
||||
type GarageKeyInfo struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Name string `json:"name"`
|
||||
Expired bool `json:"expired"`
|
||||
SecretAccessKey *string `json:"secretAccessKey,omitempty"`
|
||||
Permissions KeyPermissions `json:"permissions"`
|
||||
Buckets []KeyBucketInfo `json:"buckets"`
|
||||
Created *time.Time `json:"created,omitempty"`
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
}
|
||||
|
||||
// KeyPermissions represents permissions for an access key
|
||||
type KeyPermissions struct {
|
||||
CreateBucket bool `json:"createBucket"`
|
||||
}
|
||||
|
||||
// KeyBucketInfo represents bucket information associated with a key
|
||||
type KeyBucketInfo struct {
|
||||
ID string `json:"id"`
|
||||
GlobalAliases []string `json:"globalAliases"`
|
||||
LocalAliases []string `json:"localAliases"`
|
||||
Permissions BucketKeyPermission `json:"permissions"`
|
||||
}
|
||||
|
||||
// BucketKeyPermission represents permissions a key has on a specific bucket
|
||||
type BucketKeyPermission struct {
|
||||
Read bool `json:"read"`
|
||||
Write bool `json:"write"`
|
||||
Owner bool `json:"owner"`
|
||||
}
|
||||
|
||||
// CreateKeyRequest represents the request to create a new access key
|
||||
type CreateKeyRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
NeverExpires bool `json:"neverExpires,omitempty"`
|
||||
Allow *KeyPermissions `json:"allow,omitempty"`
|
||||
Deny *KeyPermissions `json:"deny,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateKeyRequest represents the request to update an access key
|
||||
type UpdateKeyRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
NeverExpires bool `json:"neverExpires,omitempty"`
|
||||
Allow *KeyPermissions `json:"allow,omitempty"`
|
||||
Deny *KeyPermissions `json:"deny,omitempty"`
|
||||
}
|
||||
|
||||
// ImportKeyRequest represents the request to import an existing key
|
||||
type ImportKeyRequest struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
SecretAccessKey string `json:"secretAccessKey"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// ListKeysResponseItem represents a single key in the list response
|
||||
type ListKeysResponseItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Expired bool `json:"expired"`
|
||||
Created *time.Time `json:"created,omitempty"`
|
||||
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"`
|
||||
Created time.Time `json:"created"`
|
||||
GlobalAliases []string `json:"globalAliases"`
|
||||
WebsiteAccess bool `json:"websiteAccess"`
|
||||
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
|
||||
Keys []BucketKeyInfo `json:"keys"`
|
||||
Objects int64 `json:"objects"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
UnfinishedUploads int64 `json:"unfinishedUploads"`
|
||||
UnfinishedMultipartUploads int64 `json:"unfinishedMultipartUploads"`
|
||||
UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"`
|
||||
UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"`
|
||||
Quotas *BucketQuotas `json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
// BucketWebsiteConfig represents website configuration for a bucket
|
||||
type BucketWebsiteConfig struct {
|
||||
IndexDocument string `json:"indexDocument"`
|
||||
ErrorDocument *string `json:"errorDocument,omitempty"`
|
||||
}
|
||||
|
||||
// BucketQuotas represents quota settings for a bucket
|
||||
type BucketQuotas struct {
|
||||
MaxSize *int64 `json:"maxSize,omitempty"`
|
||||
MaxObjects *int64 `json:"maxObjects,omitempty"`
|
||||
}
|
||||
|
||||
// BucketKeyInfo represents key information associated with a bucket
|
||||
type BucketKeyInfo struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Name string `json:"name"`
|
||||
Permissions BucketKeyPermission `json:"permissions"`
|
||||
BucketLocalAliases []string `json:"bucketLocalAliases"`
|
||||
}
|
||||
|
||||
// CreateBucketAdminRequest represents the request to create a bucket via Admin API
|
||||
type CreateBucketAdminRequest struct {
|
||||
GlobalAlias *string `json:"globalAlias,omitempty"`
|
||||
LocalAlias *CreateBucketLocalAlias `json:"localAlias,omitempty"`
|
||||
}
|
||||
|
||||
// CreateBucketLocalAlias represents local alias configuration when creating a bucket
|
||||
type CreateBucketLocalAlias struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Alias string `json:"alias"`
|
||||
Allow *BucketKeyPermission `json:"allow,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateBucketRequest represents the request to update bucket settings
|
||||
type UpdateBucketRequest struct {
|
||||
WebsiteAccess *UpdateBucketWebsiteAccess `json:"websiteAccess,omitempty"`
|
||||
Quotas *BucketQuotas `json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateBucketWebsiteAccess represents website access settings update
|
||||
type UpdateBucketWebsiteAccess struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IndexDocument *string `json:"indexDocument,omitempty"`
|
||||
ErrorDocument *string `json:"errorDocument,omitempty"`
|
||||
}
|
||||
|
||||
// ListBucketsResponseItem represents a single bucket in the list response
|
||||
type ListBucketsResponseItem struct {
|
||||
ID string `json:"id"`
|
||||
Created time.Time `json:"created"`
|
||||
GlobalAliases []string `json:"globalAliases"`
|
||||
LocalAliases []BucketLocalAlias `json:"localAliases"`
|
||||
}
|
||||
|
||||
// BucketLocalAlias represents a local alias for a bucket
|
||||
type BucketLocalAlias struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Alias Models
|
||||
// ====================================
|
||||
|
||||
// AddBucketAliasRequest represents the request to add a bucket alias
|
||||
type AddBucketAliasRequest struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
GlobalAlias *string `json:"globalAlias,omitempty"`
|
||||
LocalAlias *string `json:"localAlias,omitempty"`
|
||||
AccessKeyID *string `json:"accessKeyId,omitempty"`
|
||||
}
|
||||
|
||||
// RemoveBucketAliasRequest represents the request to remove a bucket alias
|
||||
type RemoveBucketAliasRequest struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
GlobalAlias *string `json:"globalAlias,omitempty"`
|
||||
LocalAlias *string `json:"localAlias,omitempty"`
|
||||
AccessKeyID *string `json:"accessKeyId,omitempty"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Permission Models
|
||||
// ====================================
|
||||
|
||||
// BucketKeyPermRequest represents a request to change bucket-key permissions
|
||||
type BucketKeyPermRequest struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Permissions BucketKeyPermission `json:"permissions"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Cluster Models
|
||||
// ====================================
|
||||
|
||||
// ClusterHealth represents the health status of the cluster
|
||||
type ClusterHealth struct {
|
||||
Status string `json:"status"`
|
||||
KnownNodes int `json:"knownNodes"`
|
||||
ConnectedNodes int `json:"connectedNodes"`
|
||||
StorageNodes int `json:"storageNodes"`
|
||||
StorageNodesUp int `json:"storageNodesUp"`
|
||||
Partitions int `json:"partitions"`
|
||||
PartitionsQuorum int `json:"partitionsQuorum"`
|
||||
PartitionsAllOk int `json:"partitionsAllOk"`
|
||||
}
|
||||
|
||||
// ClusterStatus represents the current status of the cluster
|
||||
type ClusterStatus struct {
|
||||
LayoutVersion int `json:"layoutVersion"`
|
||||
Nodes []NodeInfo `json:"nodes"`
|
||||
}
|
||||
|
||||
// ClusterStatistics represents global cluster statistics
|
||||
type ClusterStatistics struct {
|
||||
Freeform string `json:"freeform"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Node Models
|
||||
// ====================================
|
||||
|
||||
// NodeInfo represents information about a cluster node
|
||||
type NodeInfo struct {
|
||||
ID string `json:"id"`
|
||||
IsUp bool `json:"isUp"`
|
||||
LastSeenSecsAgo *int64 `json:"lastSeenSecsAgo,omitempty"`
|
||||
Hostname *string `json:"hostname,omitempty"`
|
||||
Addr *string `json:"addr,omitempty"`
|
||||
GarageVersion *string `json:"garageVersion,omitempty"`
|
||||
Role *NodeRole `json:"role,omitempty"`
|
||||
Draining bool `json:"draining"`
|
||||
DataPartition *FreeSpaceInfo `json:"dataPartition,omitempty"`
|
||||
MetadataPartition *FreeSpaceInfo `json:"metadataPartition,omitempty"`
|
||||
}
|
||||
|
||||
// NodeRole represents the role assigned to a node
|
||||
type NodeRole struct {
|
||||
Zone string `json:"zone"`
|
||||
Capacity *int64 `json:"capacity,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// FreeSpaceInfo represents disk space information
|
||||
type FreeSpaceInfo struct {
|
||||
Available int64 `json:"available"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// NodeInfoResponse represents the response for GetNodeInfo
|
||||
type NodeInfoResponse struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
GarageVersion string `json:"garageVersion"`
|
||||
RustVersion string `json:"rustVersion"`
|
||||
DBEngine string `json:"dbEngine"`
|
||||
GarageFeatures []string `json:"garageFeatures,omitempty"`
|
||||
}
|
||||
|
||||
// NodeStatisticsResponse represents the response for GetNodeStatistics
|
||||
type NodeStatisticsResponse struct {
|
||||
Freeform string `json:"freeform"`
|
||||
}
|
||||
|
||||
// MultiNodeResponse represents responses from multiple nodes
|
||||
type MultiNodeResponse struct {
|
||||
Success map[string]interface{} `json:"success"`
|
||||
Error map[string]string `json:"error"`
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package models
|
||||
|
||||
// CreateBucketRequest represents a request to create a new bucket
|
||||
type CreateBucketRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Region string `json:"region,omitempty"`
|
||||
}
|
||||
|
||||
// GrantBucketPermissionRequest represents a request to grant permissions on a bucket
|
||||
type GrantBucketPermissionRequest struct {
|
||||
AccessKeyID string `json:"accessKeyId" validate:"required"`
|
||||
Permissions BucketKeyPermission `json:"permissions" validate:"required"`
|
||||
}
|
||||
|
||||
// DeleteBucketRequest represents a request to delete a bucket
|
||||
type DeleteBucketRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
|
||||
// ListObjectsRequest represents a request to list objects in a bucket
|
||||
type ListObjectsRequest struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
MaxKeys int `json:"max_keys,omitempty"`
|
||||
Marker string `json:"marker,omitempty"`
|
||||
}
|
||||
|
||||
// UploadObjectRequest represents metadata for an object upload
|
||||
type UploadObjectRequest struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteObjectRequest represents a request to delete an object
|
||||
type DeleteObjectRequest struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
}
|
||||
|
||||
// GetObjectRequest represents a request to get/download an object
|
||||
type GetObjectRequest struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
}
|
||||
|
||||
// CreateUserRequest represents a request to create a new user/key
|
||||
type CreateUserRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteUserRequest represents a request to delete a user/key
|
||||
type DeleteUserRequest struct {
|
||||
AccessKey string `json:"access_key" validate:"required"`
|
||||
}
|
||||
|
||||
// UpdateUserRequest represents a request to update user permissions
|
||||
type UpdateUserRequest struct {
|
||||
Status *string `json:"status,omitempty"` // "active" or "inactive"
|
||||
Expiration *string `json:"expiration,omitempty"` // ISO 8601 date string
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// DashboardMetrics represents aggregated metrics for the dashboard
|
||||
type DashboardMetrics struct {
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
ObjectCount int64 `json:"objectCount"`
|
||||
BucketCount int `json:"bucketCount"`
|
||||
UsageByBucket []BucketUsage `json:"usageByBucket"`
|
||||
}
|
||||
|
||||
// BucketUsage represents storage usage for a single bucket
|
||||
type BucketUsage struct {
|
||||
BucketName string `json:"bucketName"`
|
||||
Size int64 `json:"size"`
|
||||
ObjectCount int64 `json:"objectCount"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
}
|
||||
|
||||
// APIResponse is the standard response structure for all API endpoints
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// APIError represents an error in the API response
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// HealthResponse represents the health check response
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// BucketInfo represents information about a bucket
|
||||
type BucketInfo struct {
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creation_date"`
|
||||
ObjectCount *int64 `json:"object_count,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
}
|
||||
|
||||
// BucketListResponse represents a list of buckets
|
||||
type BucketListResponse struct {
|
||||
Buckets []BucketInfo `json:"buckets"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// ObjectInfo represents information about an object
|
||||
type ObjectInfo struct {
|
||||
Key string `json:"key"`
|
||||
Size int64 `json:"size"`
|
||||
LastModified time.Time `json:"last_modified"`
|
||||
ETag string `json:"etag"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
StorageClass string `json:"storage_class,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectListResponse represents a list of objects in a bucket
|
||||
type ObjectListResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Objects []ObjectInfo `json:"objects"`
|
||||
Prefixes []string `json:"prefixes"`
|
||||
Count int `json:"count"`
|
||||
IsTruncated bool `json:"is_truncated"`
|
||||
NextContinuationToken string `json:"next_continuation_token,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectUploadResponse represents the response after uploading an object
|
||||
type ObjectUploadResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Key string `json:"key"`
|
||||
ETag string `json:"etag"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
|
||||
// ObjectUploadMultipleResponse represents the response after uploading multiple objects
|
||||
type ObjectUploadMultipleResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
TotalFiles int `json:"total_files"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
SuccessFiles []ObjectUploadResult `json:"success_files"`
|
||||
FailedFiles []ObjectUploadFailedResult `json:"failed_files,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectUploadResult represents a successful upload result
|
||||
type ObjectUploadResult struct {
|
||||
Key string `json:"key"`
|
||||
ETag string `json:"etag"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectUploadFailedResult represents a failed upload result
|
||||
type ObjectUploadFailedResult struct {
|
||||
Key string `json:"key"`
|
||||
Error string `json:"error"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
}
|
||||
|
||||
// ObjectDeleteResponse represents the response after deleting an object
|
||||
type ObjectDeleteResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Key string `json:"key"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// UserInfo represents information about a Garage user (key pair)
|
||||
type UserInfo struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
Name string `json:"name"`
|
||||
SecretKey *string `json:"secretKey,omitempty"`
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||
LastUsed *time.Time `json:"lastUsed,omitempty"`
|
||||
Status string `json:"status"` // "active" or "inactive"
|
||||
BucketPermissions []BucketPermission `json:"permissions"` // Array of bucket permissions
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
Expired bool `json:"expired"`
|
||||
}
|
||||
|
||||
// BucketPermission represents permissions for a specific bucket
|
||||
type BucketPermission struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
BucketName string `json:"bucketName"`
|
||||
Read bool `json:"read"`
|
||||
Write bool `json:"write"`
|
||||
Owner bool `json:"owner"`
|
||||
}
|
||||
|
||||
// Permission represents a permission entry for access control (legacy/deprecated)
|
||||
type Permission struct {
|
||||
Resource string `json:"resource"`
|
||||
Actions []string `json:"actions"`
|
||||
Effect string `json:"effect"` // "Allow" or "Deny"
|
||||
}
|
||||
|
||||
type PresignedURLResponse struct {
|
||||
URL string `json:"url"`
|
||||
ExpiresIn int64 `json:"expires_in"` // in seconds
|
||||
Bucket string `json:"bucket"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type ObjectDeleteMultipleResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Deleted int `json:"deleted"`
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
// UserListResponse represents a list of users/keys
|
||||
type UserListResponse struct {
|
||||
Users []UserInfo `json:"users"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// Helper functions to create standard responses
|
||||
|
||||
// SuccessResponse creates a successful API response
|
||||
func SuccessResponse(data interface{}) APIResponse {
|
||||
return APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
Error: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorResponse creates an error API response
|
||||
func ErrorResponse(code, message string) APIResponse {
|
||||
return APIResponse{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Error: &APIError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Common error codes
|
||||
const (
|
||||
ErrCodeBadRequest = "BAD_REQUEST"
|
||||
ErrCodeUnauthorized = "UNAUTHORIZED"
|
||||
ErrCodeForbidden = "FORBIDDEN"
|
||||
ErrCodeNotFound = "NOT_FOUND"
|
||||
ErrCodeConflict = "CONFLICT"
|
||||
ErrCodeInternalError = "INTERNAL_ERROR"
|
||||
ErrCodeBucketExists = "BUCKET_ALREADY_EXISTS"
|
||||
ErrCodeBucketNotFound = "BUCKET_NOT_FOUND"
|
||||
ErrCodeObjectNotFound = "OBJECT_NOT_FOUND"
|
||||
ErrCodeInvalidBucketName = "INVALID_BUCKET_NAME"
|
||||
ErrCodeInvalidObjectKey = "INVALID_OBJECT_KEY"
|
||||
ErrCodeUploadFailed = "UPLOAD_FAILED"
|
||||
ErrCodeDeleteFailed = "DELETE_FAILED"
|
||||
ErrCodeListFailed = "LIST_FAILED"
|
||||
)
|
||||
@@ -1,246 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/handlers"
|
||||
"Noooste/garage-ui/internal/middleware"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
// Swagger imports
|
||||
_ "Noooste/garage-ui/docs"
|
||||
|
||||
"github.com/Noooste/swagger"
|
||||
)
|
||||
|
||||
// SetupRoutes configures all API routes
|
||||
func SetupRoutes(
|
||||
app *fiber.App,
|
||||
cfg *config.Config,
|
||||
authService *auth.AuthService,
|
||||
healthHandler *handlers.HealthHandler,
|
||||
bucketHandler *handlers.BucketHandler,
|
||||
objectHandler *handlers.ObjectHandler,
|
||||
userHandler *handlers.UserHandler,
|
||||
clusterHandler *handlers.ClusterHandler,
|
||||
monitoringHandler *handlers.MonitoringHandler,
|
||||
) {
|
||||
// Apply CORS middleware globally
|
||||
app.Use(middleware.CORSMiddleware(&cfg.CORS))
|
||||
|
||||
// Health check endpoint (no auth required)
|
||||
app.Get("/health", healthHandler.Check)
|
||||
app.Get("/api/v1/health", healthHandler.Check)
|
||||
|
||||
// Swagger documentation endpoint (no auth required)
|
||||
app.Get("/docs/*", swagger.HandlerDefault)
|
||||
|
||||
// API v1 group
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
// Apply authentication middleware to all API routes
|
||||
api.Use(middleware.AuthMiddleware(&cfg.Auth, authService))
|
||||
|
||||
// Bucket routes
|
||||
buckets := api.Group("/buckets")
|
||||
{
|
||||
buckets.Get("/", bucketHandler.ListBuckets) // List all buckets
|
||||
buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket
|
||||
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
|
||||
}
|
||||
|
||||
// Object routes
|
||||
objects := api.Group("/buckets/:bucket/objects")
|
||||
{
|
||||
objects.Get("/", objectHandler.ListObjects) // List objects in bucket
|
||||
objects.Post("/", objectHandler.UploadObject) // Upload object (multipart)
|
||||
objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects
|
||||
objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects
|
||||
objects.Get("/:key", objectHandler.GetObject) // Download object
|
||||
objects.Delete("/:key", objectHandler.DeleteObject) // Delete object
|
||||
objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata
|
||||
objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL
|
||||
}
|
||||
|
||||
// User/Key management routes
|
||||
users := api.Group("/users")
|
||||
{
|
||||
users.Get("/", userHandler.ListUsers) // List all users/keys
|
||||
users.Post("/", userHandler.CreateUser) // Create new user/key
|
||||
users.Get("/:access_key", userHandler.GetUser) // Get user info
|
||||
users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key
|
||||
users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions
|
||||
}
|
||||
|
||||
// Cluster management routes
|
||||
cluster := api.Group("/cluster")
|
||||
{
|
||||
cluster.Get("/health", clusterHandler.GetHealth) // Get cluster health
|
||||
cluster.Get("/status", clusterHandler.GetStatus) // Get cluster status
|
||||
cluster.Get("/statistics", clusterHandler.GetStatistics) // Get cluster statistics
|
||||
cluster.Get("/nodes/:node_id", clusterHandler.GetNodeInfo) // Get node info
|
||||
cluster.Get("/nodes/:node_id/statistics", clusterHandler.GetNodeStatistics) // Get node statistics
|
||||
}
|
||||
|
||||
// Monitoring routes
|
||||
monitoring := api.Group("/monitoring")
|
||||
{
|
||||
monitoring.Get("/metrics", monitoringHandler.GetMetrics) // Get Prometheus metrics
|
||||
monitoring.Get("/admin-health", monitoringHandler.CheckAdminHealth) // Check Admin API health
|
||||
monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
|
||||
}
|
||||
|
||||
// OIDC authentication routes (only if OIDC is enabled)
|
||||
if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled {
|
||||
authRoutes := app.Group("/auth")
|
||||
{
|
||||
// Login endpoint - redirects to OIDC provider
|
||||
authRoutes.Get("/login", func(c fiber.Ctx) error {
|
||||
state, err := authService.GenerateStateToken()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Failed to generate state token",
|
||||
})
|
||||
}
|
||||
|
||||
authURL, err := authService.GetAuthorizationURL(state)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Failed to generate login URL",
|
||||
})
|
||||
}
|
||||
return c.Redirect().To(authURL)
|
||||
})
|
||||
|
||||
// Callback endpoint - handles OIDC redirect after login
|
||||
authRoutes.Get("/callback", func(c fiber.Ctx) error {
|
||||
// Get and validate state token
|
||||
state := c.Query("state")
|
||||
if !authService.ValidateAndConsumeState(state) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Invalid or expired state token",
|
||||
})
|
||||
}
|
||||
|
||||
// Get authorization code from query
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Authorization code is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
ctx := c.Context()
|
||||
token, err := authService.ExchangeCode(ctx, code)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "Failed to exchange authorization code",
|
||||
})
|
||||
}
|
||||
|
||||
// Extract ID token from OAuth2 token
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "No ID token in response",
|
||||
})
|
||||
}
|
||||
|
||||
// Verify ID token and get user info
|
||||
userInfo, err := authService.VerifyIDToken(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "Invalid ID token",
|
||||
})
|
||||
}
|
||||
|
||||
// Generate JWT session token
|
||||
sessionToken, err := authService.GenerateSessionToken(userInfo)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Failed to create session",
|
||||
})
|
||||
}
|
||||
|
||||
// Set JWT session token as secure cookie
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: cfg.Auth.OIDC.CookieName,
|
||||
Value: sessionToken,
|
||||
MaxAge: cfg.Auth.OIDC.SessionMaxAge,
|
||||
Secure: cfg.Auth.OIDC.CookieSecure,
|
||||
HTTPOnly: cfg.Auth.OIDC.CookieHTTPOnly,
|
||||
SameSite: cfg.Auth.OIDC.CookieSameSite,
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"user": userInfo,
|
||||
})
|
||||
})
|
||||
|
||||
// Logout endpoint
|
||||
authRoutes.Post("/logout", func(c fiber.Ctx) error {
|
||||
// Clear session cookie
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: cfg.Auth.OIDC.CookieName,
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"message": "Logged out successfully",
|
||||
})
|
||||
})
|
||||
|
||||
// User info endpoint
|
||||
authRoutes.Get("/me", middleware.AuthMiddleware(&cfg.Auth, authService), func(c fiber.Ctx) error {
|
||||
userInfo := c.Locals("userInfo")
|
||||
if userInfo == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "Not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"user": userInfo,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check if frontend path exists
|
||||
if _, err := os.Stat(cfg.Server.FrontendPath); err == nil {
|
||||
fmt.Println("Serving frontend from:", cfg.Server.FrontendPath)
|
||||
|
||||
// SPA fallback - serve index.html for all non-API routes
|
||||
app.Use(func(c fiber.Ctx) error {
|
||||
path := c.Path()
|
||||
|
||||
if strings.HasPrefix(path, "/api/") ||
|
||||
strings.HasPrefix(path, "/health") ||
|
||||
strings.HasPrefix(path, "/docs") {
|
||||
fmt.Println("API or health check route, skipping SPA fallback:", path)
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Try to serve static files first
|
||||
filePath := filepath.Join(cfg.Server.FrontendPath, path)
|
||||
if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
|
||||
return c.SendFile(filePath)
|
||||
}
|
||||
|
||||
// If no static file exists, serve index.html for SPA routing
|
||||
indexPath := filepath.Join(cfg.Server.FrontendPath, "index.html")
|
||||
return c.SendFile(indexPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/Noooste/azuretls-client"
|
||||
)
|
||||
|
||||
// GarageAdminService handles interactions with the Garage Admin API
|
||||
type GarageAdminService struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *azuretls.Session
|
||||
}
|
||||
|
||||
// NewGarageAdminService creates a new Garage Admin API service
|
||||
func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
|
||||
session := azuretls.NewSession()
|
||||
session.Log()
|
||||
|
||||
return &GarageAdminService{
|
||||
baseURL: cfg.AdminEndpoint,
|
||||
token: cfg.AdminToken,
|
||||
httpClient: session,
|
||||
}
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP request to the Admin API
|
||||
func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
|
||||
return s.httpClient.Do(&azuretls.Request{
|
||||
Method: method,
|
||||
Url: s.baseURL + path,
|
||||
Body: body,
|
||||
IgnoreBody: true, // decodeResponse will handle body reading
|
||||
OrderedHeaders: azuretls.OrderedHeaders{
|
||||
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
|
||||
},
|
||||
}, ctx)
|
||||
}
|
||||
|
||||
// decodeResponse decodes a JSON response into the target structure
|
||||
func decodeResponse(resp *azuretls.Response, target interface{}) error {
|
||||
defer resp.RawBody.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(resp.RawBody)
|
||||
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
if target != nil {
|
||||
if err := json.NewDecoder(resp.RawBody).Decode(target); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result []models.ListKeysResponseItem
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateKey creates a new API access key
|
||||
func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateKey", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetKeyInfo returns information about a specific access key
|
||||
func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
path := fmt.Sprintf("/v2/GetKeyInfo?id=%s", keyID)
|
||||
if showSecret {
|
||||
path += "&showSecretKey=true"
|
||||
}
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// UpdateKey updates information about an access key
|
||||
func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
path := fmt.Sprintf("/v2/UpdateKey?id=%s", keyID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DeleteKey deletes an access key from the cluster
|
||||
func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error {
|
||||
path := fmt.Sprintf("/v2/DeleteKey?id=%s", keyID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if err := decodeResponse(resp, nil); err != nil {
|
||||
return fmt.Errorf("failed to process response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportKey imports an existing API access key
|
||||
func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/ImportKey", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result []models.ListBucketsResponseItem
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetBucketInfo returns detailed information about a bucket by ID
|
||||
func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
|
||||
path := fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetBucketInfoByAlias returns detailed information about a bucket by its global alias
|
||||
func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
|
||||
path := fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CreateBucket creates a new bucket via the Admin API
|
||||
func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateBucket", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// UpdateBucket updates bucket settings
|
||||
func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
path := fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DeleteBucket deletes a bucket
|
||||
func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error {
|
||||
path := fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if err := decodeResponse(resp, nil); err != nil {
|
||||
return fmt.Errorf("failed to process response: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// RemoveBucketAlias removes an alias from a bucket
|
||||
func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/RemoveBucketAlias", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DenyBucketKey revokes permissions for a key on a bucket
|
||||
func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/DenyBucketKey", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.ClusterHealth
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetClusterStatus returns the current status of the cluster
|
||||
func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatus", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.ClusterStatus
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetClusterStatistics returns global cluster statistics
|
||||
func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatistics", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.ClusterStatistics
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.MultiNodeResponse
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetNodeStatistics returns statistics for a specific node
|
||||
func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
path := fmt.Sprintf("/v2/GetNodeStatistics?node=%s", nodeID)
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result models.MultiNodeResponse
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("health check failed: %w", err)
|
||||
}
|
||||
|
||||
if err := decodeResponse(resp, nil); err != nil {
|
||||
return fmt.Errorf("health check returned error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMetrics returns Prometheus metrics from the Admin API
|
||||
func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.RawBody.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(resp.RawBody)
|
||||
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.RawBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return string(bodyBytes), nil
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
// S3Service handles all S3 operations with Garage using MinIO SDK
|
||||
type S3Service struct {
|
||||
client *minio.Client
|
||||
config *config.GarageConfig
|
||||
adminService *GarageAdminService
|
||||
}
|
||||
|
||||
// NewS3Service creates a new S3 service instance using MinIO SDK
|
||||
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
|
||||
// Create MinIO client for Garage
|
||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
//Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to create MinIO client: %w", err))
|
||||
}
|
||||
|
||||
return &S3Service{
|
||||
client: client,
|
||||
config: cfg,
|
||||
adminService: adminService,
|
||||
}
|
||||
}
|
||||
|
||||
// getBucketCredentials retrieves credentials for a specific bucket
|
||||
// It checks the cache first, then queries the Garage Admin API
|
||||
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (*credentials.Credentials, error) {
|
||||
cacheKey := fmt.Sprintf("key:%s", bucketName)
|
||||
cacheData := utils.GlobalCache.Get(cacheKey)
|
||||
|
||||
if cacheData != nil {
|
||||
return cacheData.(*credentials.Credentials), nil
|
||||
}
|
||||
|
||||
// Get bucket info from Garage Admin API
|
||||
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get bucket info: %w", err)
|
||||
}
|
||||
|
||||
// Find a key with read and write permissions
|
||||
var accessKeyID, secretAccessKey string
|
||||
for _, keyInfo := range bucketInfo.Keys {
|
||||
if !keyInfo.Permissions.Read || !keyInfo.Permissions.Write {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get key details with secret
|
||||
keyDetails, err := s.adminService.GetKeyInfo(ctx, keyInfo.AccessKeyID, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get key info: %w", err)
|
||||
}
|
||||
|
||||
if keyDetails.SecretAccessKey != nil {
|
||||
accessKeyID = keyDetails.AccessKeyID
|
||||
secretAccessKey = *keyDetails.SecretAccessKey
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if accessKeyID == "" || secretAccessKey == "" {
|
||||
return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName)
|
||||
}
|
||||
|
||||
// Create credentials
|
||||
creds := credentials.NewStaticV4(accessKeyID, secretAccessKey, "")
|
||||
|
||||
// Cache credentials for 1 hour
|
||||
utils.GlobalCache.Set(cacheKey, creds, time.Hour)
|
||||
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// getMinioClient creates a MinIO client for a specific bucket with dynamic credentials
|
||||
func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*minio.Client, error) {
|
||||
creds, err := s.getBucketCredentials(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Create MinIO client with bucket-specific credentials
|
||||
client, err := minio.New(s.config.Endpoint, &minio.Options{
|
||||
Creds: creds,
|
||||
Secure: s.config.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// ListBuckets retrieves all buckets from Garage
|
||||
func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) {
|
||||
// Call MinIO ListBuckets API
|
||||
bucketInfos, err := s.client.ListBuckets(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list buckets: %w", err)
|
||||
}
|
||||
|
||||
// Convert MinIO buckets to our model
|
||||
buckets := make([]models.BucketInfo, 0, len(bucketInfos))
|
||||
for _, bucket := range bucketInfos {
|
||||
buckets = append(buckets, models.BucketInfo{
|
||||
Name: bucket.Name,
|
||||
CreationDate: bucket.CreationDate,
|
||||
})
|
||||
}
|
||||
|
||||
return &models.BucketListResponse{
|
||||
Buckets: buckets,
|
||||
Count: len(buckets),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateBucket creates a new bucket in Garage
|
||||
func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
|
||||
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 MakeBucket API
|
||||
err = client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
|
||||
Region: s.config.Region,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBucket deletes a bucket from Garage
|
||||
func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
|
||||
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 RemoveBucket API
|
||||
err = client.RemoveBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListObjects lists objects in a bucket with optional prefix filter and pagination
|
||||
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Set default max keys if not specified
|
||||
if maxKeys <= 0 {
|
||||
maxKeys = 1000
|
||||
}
|
||||
|
||||
// Use ListObjectsV2 for proper pagination support
|
||||
opts := minio.ListObjectsOptions{
|
||||
Prefix: prefix,
|
||||
Recursive: false,
|
||||
MaxKeys: maxKeys,
|
||||
StartAfter: continuationToken,
|
||||
UseV1: false,
|
||||
}
|
||||
|
||||
objects := make([]models.ObjectInfo, 0)
|
||||
prefixesMap := make(map[string]bool)
|
||||
|
||||
var lastKey string
|
||||
isTruncated := false
|
||||
itemCount := 0
|
||||
|
||||
// List objects using the channel-based API
|
||||
for object := range client.ListObjects(ctx, bucketName, opts) {
|
||||
if object.Err != nil {
|
||||
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, object.Err)
|
||||
}
|
||||
|
||||
// Check if this is a prefix (directory)
|
||||
if len(object.Key) > 0 && object.Key[len(object.Key)-1:] == "/" && object.Size == 0 {
|
||||
prefixesMap[object.Key] = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Track the last key for pagination
|
||||
lastKey = object.Key
|
||||
|
||||
// Add to objects list
|
||||
objects = append(objects, models.ObjectInfo{
|
||||
Key: object.Key,
|
||||
Size: object.Size,
|
||||
LastModified: object.LastModified,
|
||||
ETag: object.ETag,
|
||||
ContentType: object.ContentType,
|
||||
StorageClass: object.StorageClass,
|
||||
})
|
||||
|
||||
itemCount++
|
||||
if itemCount >= maxKeys {
|
||||
isTruncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Convert prefixes map to slice
|
||||
prefixList := make([]string, 0, len(prefixesMap))
|
||||
for p := range prefixesMap {
|
||||
prefixList = append(prefixList, p)
|
||||
}
|
||||
|
||||
// Prepare next continuation token
|
||||
var nextToken string
|
||||
if isTruncated && lastKey != "" {
|
||||
nextToken = lastKey
|
||||
}
|
||||
|
||||
return &models.ObjectListResponse{
|
||||
Bucket: bucketName,
|
||||
Objects: objects,
|
||||
Prefixes: prefixList,
|
||||
Count: len(objects),
|
||||
IsTruncated: isTruncated,
|
||||
NextContinuationToken: nextToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UploadObject uploads an object to a bucket
|
||||
func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Upload options
|
||||
opts := minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
}
|
||||
|
||||
// Call MinIO PutObject API
|
||||
info, err := client.PutObject(ctx, bucketName, key, body, -1, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
|
||||
return &models.ObjectUploadResponse{
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
ETag: info.ETag,
|
||||
Size: info.Size,
|
||||
ContentType: contentType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetObject retrieves an object from a bucket
|
||||
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
// Call MinIO GetObject API
|
||||
object, err := s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
|
||||
// Get object info
|
||||
stat, err := object.Stat()
|
||||
if err != nil {
|
||||
object.Close()
|
||||
return nil, nil, fmt.Errorf("failed to get object info for %s in bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
|
||||
// Create object info
|
||||
objectInfo := &models.ObjectInfo{
|
||||
Key: key,
|
||||
Size: stat.Size,
|
||||
LastModified: stat.LastModified,
|
||||
ETag: stat.ETag,
|
||||
ContentType: stat.ContentType,
|
||||
}
|
||||
|
||||
return object, objectInfo, nil
|
||||
}
|
||||
|
||||
// DeleteObject deletes an object from a bucket
|
||||
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
|
||||
// Call MinIO RemoveObject API
|
||||
err := s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ObjectExists checks if an object exists in a bucket
|
||||
func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
_, err = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
// Check if error is "object not found"
|
||||
errResponse := minio.ToErrorResponse(err)
|
||||
if errResponse.Code == "NoSuchKey" {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("failed to check if object exists: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetObjectMetadata retrieves metadata for an object without downloading it
|
||||
func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
stat, err := client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
|
||||
return &models.ObjectInfo{
|
||||
Key: key,
|
||||
Size: stat.Size,
|
||||
LastModified: stat.LastModified,
|
||||
ETag: stat.ETag,
|
||||
ContentType: stat.ContentType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteMultipleObjects deletes multiple objects from a bucket
|
||||
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Create channel for objects to delete
|
||||
objectsCh := make(chan minio.ObjectInfo)
|
||||
|
||||
// Send objects to delete in a goroutine
|
||||
go func() {
|
||||
defer close(objectsCh)
|
||||
for _, key := range keys {
|
||||
objectsCh <- minio.ObjectInfo{
|
||||
Key: key,
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Call MinIO RemoveObjects API (batch delete)
|
||||
errorCh := client.RemoveObjects(ctx, bucketName, objectsCh, minio.RemoveObjectsOptions{})
|
||||
|
||||
// Check for errors
|
||||
for err := range errorCh {
|
||||
if err.Err != nil {
|
||||
return fmt.Errorf("failed to delete object %s from bucket %s: %w", err.ObjectName, bucketName, err.Err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPresignedURL generates a pre-signed URL for temporary access to an object
|
||||
// This is useful for sharing files without exposing credentials
|
||||
func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Generate presigned GET URL
|
||||
presignedURL, err := client.PresignedGetObject(ctx, bucketName, key, expiresIn, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err)
|
||||
}
|
||||
|
||||
return presignedURL.String(), nil
|
||||
}
|
||||
|
||||
// UploadResult represents the result of a single file upload
|
||||
type UploadResult struct {
|
||||
Key string
|
||||
Success bool
|
||||
Error error
|
||||
ETag string
|
||||
Size int64
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// UploadMultipleObjects uploads multiple objects to a bucket
|
||||
// It handles uploads in batches to respect any S3/Garage limits
|
||||
// Returns results for each file, including both successes and failures
|
||||
func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}) []UploadResult {
|
||||
results := make([]UploadResult, len(files))
|
||||
|
||||
// Get bucket-specific MinIO client once for all uploads
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
if err != nil {
|
||||
// If we can't get the client, all uploads fail
|
||||
for i := range files {
|
||||
results[i] = UploadResult{
|
||||
Key: files[i].Key,
|
||||
Success: false,
|
||||
Error: fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err),
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Upload each file
|
||||
for i, file := range files {
|
||||
// Upload options
|
||||
opts := minio.PutObjectOptions{
|
||||
ContentType: file.ContentType,
|
||||
}
|
||||
|
||||
// Attempt upload
|
||||
info, err := client.PutObject(ctx, bucketName, file.Key, file.Body, -1, opts)
|
||||
if err != nil {
|
||||
results[i] = UploadResult{
|
||||
Key: file.Key,
|
||||
Success: false,
|
||||
Error: fmt.Errorf("failed to upload object %s: %w", file.Key, err),
|
||||
ContentType: file.ContentType,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
results[i] = UploadResult{
|
||||
Key: file.Key,
|
||||
Success: true,
|
||||
Error: nil,
|
||||
ETag: info.ETag,
|
||||
Size: info.Size,
|
||||
ContentType: file.ContentType,
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// BucketStatistics holds statistical information about a bucket
|
||||
type BucketStatistics struct {
|
||||
ObjectCount int64
|
||||
TotalSize int64
|
||||
}
|
||||
|
||||
// GetBucketStatistics retrieves bucket statistics from Garage Admin API
|
||||
// This is much more efficient than iterating through all objects
|
||||
func (s *S3Service) GetBucketStatistics(ctx context.Context, bucketName string) (*BucketStatistics, error) {
|
||||
// Get bucket info from Garage Admin API which includes object count and size
|
||||
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get bucket info for %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Return statistics from Admin API
|
||||
return &BucketStatistics{
|
||||
ObjectCount: bucketInfo.Objects,
|
||||
TotalSize: bucketInfo.Bytes,
|
||||
}, nil
|
||||
}
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/handlers"
|
||||
"Noooste/garage-ui/internal/routes"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/logger"
|
||||
"github.com/gofiber/fiber/v3/middleware/recover"
|
||||
)
|
||||
|
||||
// @title Garage UI API
|
||||
// @version 0.1.0
|
||||
// @description REST API for managing Garage distributed object storage system
|
||||
// @description This API provides endpoints for managing buckets, objects, users, and cluster operations.
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
|
||||
// @contact.name API Support
|
||||
// @contact.email support@garage-ui.io
|
||||
|
||||
// @license.name MIT
|
||||
// @license.url https://opensource.org/licenses/MIT
|
||||
|
||||
// @host localhost:8080
|
||||
// @BasePath /
|
||||
// @schemes http https
|
||||
|
||||
// @tag.name Health
|
||||
// @tag.description Health check endpoints
|
||||
|
||||
// @tag.name Buckets
|
||||
// @tag.description Bucket management operations
|
||||
|
||||
// @tag.name Objects
|
||||
// @tag.description Object storage and retrieval operations
|
||||
|
||||
// @tag.name Users
|
||||
// @tag.description User and access key management
|
||||
|
||||
// @tag.name Cluster
|
||||
// @tag.description Cluster status and node management
|
||||
|
||||
// @tag.name Monitoring
|
||||
// @tag.description Monitoring and metrics endpoints
|
||||
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Type "Bearer" followed by a space and JWT token.
|
||||
|
||||
const version = "0.1.0"
|
||||
|
||||
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)
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load configuration: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Starting Garage UI Backend v%s in %s mode", version, cfg.Server.Environment)
|
||||
|
||||
// Initialize services
|
||||
log.Println("Initializing Garage Admin service...")
|
||||
adminService := services.NewGarageAdminService(&cfg.Garage)
|
||||
|
||||
log.Println("Initializing S3 service...")
|
||||
s3Service := services.NewS3Service(&cfg.Garage, adminService)
|
||||
|
||||
log.Printf("Initializing authentication service (mode: %s)...", cfg.Auth.Mode)
|
||||
authService, err := auth.NewAuthService(&cfg.Auth)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize auth service: %v", err)
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
bucketHandler := handlers.NewBucketHandler(adminService, s3Service)
|
||||
objectHandler := handlers.NewObjectHandler(s3Service)
|
||||
userHandler := handlers.NewUserHandler(adminService)
|
||||
clusterHandler := handlers.NewClusterHandler(adminService)
|
||||
monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service)
|
||||
|
||||
// Create Fiber app with configuration
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Garage UI Backend v" + version,
|
||||
//DisableStartupMessage: false,
|
||||
//EnablePrintRoutes: cfg.IsDevelopment(),
|
||||
ErrorHandler: customErrorHandler,
|
||||
})
|
||||
|
||||
// Apply global middleware
|
||||
app.Use(recover.New()) // Panic recovery
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "[${time}] ${status} - ${method} ${path} (${latency})\n",
|
||||
}))
|
||||
|
||||
// Setup routes
|
||||
log.Println("Setting up routes...")
|
||||
routes.SetupRoutes(
|
||||
app,
|
||||
cfg,
|
||||
authService,
|
||||
healthHandler,
|
||||
bucketHandler,
|
||||
objectHandler,
|
||||
userHandler,
|
||||
clusterHandler,
|
||||
monitoringHandler,
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
if err := app.Listen(addr); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal to gracefully shutdown the server
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
if err := app.Shutdown(); err != nil {
|
||||
log.Fatalf("Server shutdown failed: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Server stopped gracefully")
|
||||
}
|
||||
|
||||
// customErrorHandler handles errors globally
|
||||
func customErrorHandler(c fiber.Ctx, err error) error {
|
||||
// Default to 500 Internal Server Error
|
||||
code := fiber.StatusInternalServerError
|
||||
|
||||
// Check if it's a Fiber error
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
// Log the error
|
||||
log.Printf("Error: %v", err)
|
||||
|
||||
// Return JSON error response
|
||||
return c.Status(code).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"error": fiber.Map{
|
||||
"code": fmt.Sprintf("ERROR_%d", code),
|
||||
"message": err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Logger is a wrapper around zerolog.Logger
|
||||
type Logger struct {
|
||||
zerolog.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
// Global logger instance
|
||||
globalLogger *Logger
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
output = zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: time.RFC3339,
|
||||
NoColor: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse log level
|
||||
level := zerolog.InfoLevel
|
||||
switch cfg.Level {
|
||||
case "debug":
|
||||
level = zerolog.DebugLevel
|
||||
case "info":
|
||||
level = zerolog.InfoLevel
|
||||
case "warn":
|
||||
level = zerolog.WarnLevel
|
||||
case "error":
|
||||
level = zerolog.ErrorLevel
|
||||
}
|
||||
|
||||
// Create logger
|
||||
logger := zerolog.New(output).
|
||||
Level(level).
|
||||
With().
|
||||
Timestamp().
|
||||
Caller().
|
||||
Logger()
|
||||
|
||||
globalLogger = &Logger{logger}
|
||||
log.Logger = logger
|
||||
}
|
||||
|
||||
// Get returns the global logger instance
|
||||
func Get() *Logger {
|
||||
if globalLogger == nil {
|
||||
// Initialize with defaults if not initialized
|
||||
Init(Config{
|
||||
Level: "info",
|
||||
Pretty: true,
|
||||
})
|
||||
}
|
||||
return globalLogger
|
||||
}
|
||||
|
||||
// Debug logs a debug message
|
||||
func Debug() *zerolog.Event {
|
||||
return Get().Debug()
|
||||
}
|
||||
|
||||
// Info logs an info message
|
||||
func Info() *zerolog.Event {
|
||||
return Get().Info()
|
||||
}
|
||||
|
||||
// Warn logs a warning message
|
||||
func Warn() *zerolog.Event {
|
||||
return Get().Warn()
|
||||
}
|
||||
|
||||
// Error logs an error message
|
||||
func Error() *zerolog.Event {
|
||||
return Get().Error()
|
||||
}
|
||||
|
||||
// Fatal logs a fatal message and exits
|
||||
func Fatal() *zerolog.Event {
|
||||
return Get().Fatal()
|
||||
}
|
||||
|
||||
// WithContext creates a new logger with additional context fields
|
||||
func (l *Logger) WithContext(fields map[string]interface{}) *Logger {
|
||||
ctx := l.Logger.With()
|
||||
for k, v := range fields {
|
||||
ctx = ctx.Interface(k, v)
|
||||
}
|
||||
return &Logger{ctx.Logger()}
|
||||
}
|
||||
|
||||
// WithComponent creates a logger with a component field
|
||||
func WithComponent(component string) *Logger {
|
||||
return &Logger{Get().With().Str("component", component).Logger()}
|
||||
}
|
||||
|
||||
// WithError creates a logger with an error field
|
||||
func WithError(err error) *zerolog.Event {
|
||||
return Get().Error().Err(err)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheItem represents a cached item with expiration
|
||||
type CacheItem struct {
|
||||
Value interface{}
|
||||
Expiration time.Time
|
||||
}
|
||||
|
||||
// Cache represents a simple in-memory cache with expiration
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]CacheItem
|
||||
}
|
||||
|
||||
// NewCache creates a new cache instance
|
||||
func NewCache() *Cache {
|
||||
c := &Cache{
|
||||
items: make(map[string]CacheItem),
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go c.cleanupExpired()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// Get retrieves a value from the cache
|
||||
func (c *Cache) Get(key string) interface{} {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
item, exists := c.items[key]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if item has expired
|
||||
if time.Now().After(item.Expiration) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return item.Value
|
||||
}
|
||||
|
||||
// Set stores a value in the cache with an expiration duration
|
||||
func (c *Cache) Set(key string, value interface{}, duration time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.items[key] = CacheItem{
|
||||
Value: value,
|
||||
Expiration: time.Now().Add(duration),
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removes a value from the cache
|
||||
func (c *Cache) Delete(key string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
delete(c.items, key)
|
||||
}
|
||||
|
||||
// Clear removes all items from the cache
|
||||
func (c *Cache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.items = make(map[string]CacheItem)
|
||||
}
|
||||
|
||||
// cleanupExpired periodically removes expired items
|
||||
func (c *Cache) cleanupExpired() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
c.mu.Lock()
|
||||
now := time.Now()
|
||||
for key, item := range c.items {
|
||||
if now.After(item.Expiration) {
|
||||
delete(c.items, key)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Global cache instance
|
||||
var GlobalCache = NewCache()
|
||||
@@ -1,92 +0,0 @@
|
||||
# Garage UI Backend Configuration
|
||||
|
||||
# Server configuration
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
environment: "development" # development, production
|
||||
|
||||
# Garage S3 Configuration
|
||||
garage:
|
||||
endpoint: "http://localhost:3900" # Garage S3 API endpoint
|
||||
region: "eu-west-1" # S3 region (can be any value for Garage)
|
||||
|
||||
# Garage Admin API configuration
|
||||
admin_endpoint: "http://localhost:3903" # Garage Admin API endpoint
|
||||
admin_token: "changeme" # Admin API bearer token
|
||||
|
||||
# Authentication Configuration
|
||||
auth:
|
||||
# Auth mode: "none", "basic", or "oidc"
|
||||
mode: "none"
|
||||
|
||||
# Basic Authentication (only used when mode = "basic")
|
||||
basic:
|
||||
username: "admin"
|
||||
password: "changeme"
|
||||
|
||||
# OIDC Configuration (only used when mode = "oidc")
|
||||
oidc:
|
||||
enabled: true
|
||||
provider_name: "Keycloak"
|
||||
client_id: "garage-ui"
|
||||
client_secret: "your-client-secret"
|
||||
|
||||
# OIDC scopes to request
|
||||
scopes:
|
||||
- openid
|
||||
- email
|
||||
- profile
|
||||
|
||||
# OIDC Provider URLs
|
||||
issuer_url: "https://keycloak.example.com/realms/master"
|
||||
auth_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/auth"
|
||||
token_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/token"
|
||||
userinfo_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo"
|
||||
|
||||
# Token validation
|
||||
skip_issuer_check: false
|
||||
skip_expiry_check: false
|
||||
|
||||
# Attribute mappings
|
||||
email_attribute: "email"
|
||||
username_attribute: "preferred_username"
|
||||
name_attribute: "name"
|
||||
|
||||
# Role-based access (optional)
|
||||
role_attribute_path: "resource_access.garage-ui.roles"
|
||||
admin_role: "admin"
|
||||
|
||||
# TLS configuration
|
||||
tls_skip_verify: false # Only set to true for testing, not recommended for production
|
||||
|
||||
# Session configuration
|
||||
session_max_age: 86400 # 24 hours in seconds
|
||||
cookie_name: "garage_session"
|
||||
cookie_secure: false # Set to true in production with HTTPS
|
||||
cookie_http_only: true
|
||||
cookie_same_site: "lax" # lax, strict, none
|
||||
|
||||
# CORS Configuration (for frontend)
|
||||
cors:
|
||||
enabled: true
|
||||
allowed_origins:
|
||||
- "*" # Vite default
|
||||
allowed_methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
allowed_headers:
|
||||
- Origin
|
||||
- Content-Type
|
||||
- Accept
|
||||
- Authorization
|
||||
allow_credentials: false
|
||||
max_age: 3600
|
||||
|
||||
# Logging
|
||||
logging:
|
||||
level: "info" # debug, info, warn, error
|
||||
format: "json" # json, text
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
services:
|
||||
garage:
|
||||
image: dxflrs/garage:v2.0.0
|
||||
container_name: garage
|
||||
volumes:
|
||||
- ./garage.toml:/etc/garage.toml
|
||||
- ./meta:/var/lib/garage/meta
|
||||
- ./data:/var/lib/garage/data
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3900:3900"
|
||||
- "3901:3901"
|
||||
- "3902:3903"
|
||||
- "3903:3903"
|
||||
|
||||
garage-ui:
|
||||
image: noooste/garage-ui:latest
|
||||
container_name: garage-ui
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
depends_on:
|
||||
- garage
|
||||
environment:
|
||||
# Garage S3 Configuration
|
||||
GARAGE_UI_GARAGE_ENDPOINT: "http://garage:3900"
|
||||
GARAGE_UI_GARAGE_ADMIN_ENDPOINT: "http://garage:3903"
|
||||
|
||||
# Server Configuration
|
||||
GARAGE_UI_SERVER_HOST: "0.0.0.0"
|
||||
GARAGE_UI_SERVER_PORT: "8080"
|
||||
GARAGE_UI_SERVER_ENVIRONMENT: "production"
|
||||
|
||||
# Logging
|
||||
GARAGE_UI_LOGGING_LEVEL: "info"
|
||||
GARAGE_UI_LOGGING_FORMAT: "json"
|
||||
@@ -1,23 +0,0 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -1,19 +0,0 @@
|
||||
apiVersion: v2
|
||||
name: garage-ui
|
||||
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "latest"
|
||||
keywords:
|
||||
- garage
|
||||
- s3
|
||||
- object-storage
|
||||
- ui
|
||||
- dashboard
|
||||
- web-ui
|
||||
home: https://github.com/Noooste/garage-ui
|
||||
sources:
|
||||
- https://github.com/Noooste/garage-ui
|
||||
maintainers:
|
||||
- name: Noooste
|
||||
kubeVersion: ">=1.19.0-0"
|
||||
@@ -1,396 +0,0 @@
|
||||
# Garage UI Helm Chart
|
||||
|
||||
A Helm chart for deploying Garage UI, a web interface for [Garage](https://garagehq.deuxfleurs.fr/) S3 object storage.
|
||||
|
||||
## Introduction
|
||||
|
||||
This chart bootstraps a Garage UI deployment on a Kubernetes cluster using the Helm package manager. Garage UI provides a user-friendly web interface for managing your Garage S3 storage, including buckets, objects, users, and cluster monitoring.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Kubernetes 1.19+
|
||||
- Helm 3.0+
|
||||
- A running Garage S3 instance with Admin API access
|
||||
|
||||
## Installing the Chart
|
||||
|
||||
To install the chart with the release name `my-garage-ui`:
|
||||
|
||||
```bash
|
||||
helm install my-garage-ui ./helm/garage-ui
|
||||
```
|
||||
|
||||
The command deploys Garage UI on the Kubernetes cluster with default configuration. The [Parameters](#parameters) section lists the parameters that can be configured during installation.
|
||||
|
||||
## Uninstalling the Chart
|
||||
|
||||
To uninstall/delete the `my-garage-ui` deployment:
|
||||
|
||||
```bash
|
||||
helm uninstall my-garage-ui
|
||||
```
|
||||
|
||||
The command removes all the Kubernetes components associated with the chart and deletes the release.
|
||||
|
||||
## Parameters
|
||||
|
||||
### Common Parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `replicaCount` | Number of Garage UI replicas | `1` |
|
||||
| `image.repository` | Garage UI image repository | `noooste/garage-ui` |
|
||||
| `image.tag` | Garage UI image tag (overrides Chart appVersion) | `""` |
|
||||
| `image.pullPolicy` | Image pull policy | `IfNotPresent` |
|
||||
| `imagePullSecrets` | Image pull secrets | `[]` |
|
||||
| `nameOverride` | Override chart name | `""` |
|
||||
| `fullnameOverride` | Override full resource names | `""` |
|
||||
|
||||
### Service Parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `service.type` | Kubernetes service type | `ClusterIP` |
|
||||
| `service.port` | Service port | `80` |
|
||||
|
||||
### Configuration Parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `config` | Complete config.yaml content as multiline string | See `values.yaml` |
|
||||
|
||||
**Important:** The `config` parameter contains the entire application configuration including Garage endpoints, authentication settings, CORS, and logging. You must customize this section with your Garage S3 endpoints and admin token.
|
||||
|
||||
### Ingress Parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `ingress.enabled` | Enable ingress controller resource | `false` |
|
||||
| `ingress.className` | Ingress class name | `nginx` |
|
||||
| `ingress.annotations` | Ingress annotations | `{}` |
|
||||
| `ingress.hosts` | Ingress hosts configuration | See `values.yaml` |
|
||||
| `ingress.tls` | Ingress TLS configuration | `[]` |
|
||||
|
||||
### Monitoring Parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `serviceMonitor.enabled` | Create ServiceMonitor resource (requires Prometheus Operator) | `false` |
|
||||
| `serviceMonitor.interval` | Scrape interval | `30s` |
|
||||
| `serviceMonitor.path` | Metrics endpoint path | `/api/v1/monitoring/metrics` |
|
||||
| `serviceMonitor.labels` | Additional labels for ServiceMonitor | `{}` |
|
||||
|
||||
### Network Policy Parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `networkPolicy.enabled` | Enable NetworkPolicy | `false` |
|
||||
| `networkPolicy.policyTypes` | Policy types | `["Ingress", "Egress"]` |
|
||||
|
||||
### Resource Management Parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `resources.limits.cpu` | CPU limit | `500m` |
|
||||
| `resources.limits.memory` | Memory limit | `512Mi` |
|
||||
| `resources.requests.cpu` | CPU request | `100m` |
|
||||
| `resources.requests.memory` | Memory request | `128Mi` |
|
||||
|
||||
### Health Check Parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `livenessProbe.enabled` | Enable liveness probe | `true` |
|
||||
| `readinessProbe.enabled` | Enable readiness probe | `true` |
|
||||
|
||||
### Other Parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `podAnnotations` | Pod annotations | `{}` |
|
||||
| `podSecurityContext` | Pod security context | See `values.yaml` |
|
||||
| `securityContext` | Container security context | See `values.yaml` |
|
||||
| `nodeSelector` | Node labels for pod assignment | `{}` |
|
||||
| `tolerations` | Tolerations for pod assignment | `[]` |
|
||||
| `affinity` | Affinity for pod assignment | `{}` |
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Example 1: Basic Installation with Custom Garage Endpoints
|
||||
|
||||
Create a file named `custom-values.yaml`:
|
||||
|
||||
```yaml
|
||||
config: |
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
environment: "production"
|
||||
|
||||
garage:
|
||||
endpoint: "http://garage-s3.garage.svc.cluster.local:3900"
|
||||
region: "us-east-1"
|
||||
admin_endpoint: "http://garage-admin.garage.svc.cluster.local:3903"
|
||||
admin_token: "YOUR_ADMIN_TOKEN_HERE"
|
||||
|
||||
auth:
|
||||
mode: "none"
|
||||
|
||||
cors:
|
||||
enabled: true
|
||||
allowed_origins:
|
||||
- "*"
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
```
|
||||
|
||||
Install the chart:
|
||||
|
||||
```bash
|
||||
helm install my-garage-ui ./helm/garage-ui -f custom-values.yaml
|
||||
```
|
||||
|
||||
### Example 2: With Ingress and TLS
|
||||
|
||||
```yaml
|
||||
config: |
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
environment: "production"
|
||||
|
||||
garage:
|
||||
endpoint: "http://garage:3900"
|
||||
region: "garage"
|
||||
admin_endpoint: "http://garage:3903"
|
||||
admin_token: "YOUR_ADMIN_TOKEN"
|
||||
|
||||
auth:
|
||||
mode: "none"
|
||||
|
||||
cors:
|
||||
enabled: true
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
hosts:
|
||||
- host: garage-ui.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: garage-ui-tls
|
||||
hosts:
|
||||
- garage-ui.example.com
|
||||
```
|
||||
|
||||
### Example 3: With Basic Authentication
|
||||
|
||||
```yaml
|
||||
config: |
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
environment: "production"
|
||||
|
||||
garage:
|
||||
endpoint: "http://garage:3900"
|
||||
region: "garage"
|
||||
admin_endpoint: "http://garage:3903"
|
||||
admin_token: "YOUR_ADMIN_TOKEN"
|
||||
|
||||
auth:
|
||||
mode: "basic"
|
||||
basic:
|
||||
username: "admin"
|
||||
password: "your-secure-password"
|
||||
|
||||
cors:
|
||||
enabled: true
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
```
|
||||
|
||||
### Example 4: With OIDC Authentication (Keycloak)
|
||||
|
||||
```yaml
|
||||
config: |
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
environment: "production"
|
||||
|
||||
garage:
|
||||
endpoint: "http://garage:3900"
|
||||
region: "garage"
|
||||
admin_endpoint: "http://garage:3903"
|
||||
admin_token: "YOUR_ADMIN_TOKEN"
|
||||
|
||||
auth:
|
||||
mode: "oidc"
|
||||
oidc:
|
||||
enabled: true
|
||||
provider_name: "Keycloak"
|
||||
client_id: "garage-ui"
|
||||
client_secret: "YOUR_OIDC_CLIENT_SECRET"
|
||||
scopes:
|
||||
- openid
|
||||
- email
|
||||
- profile
|
||||
issuer_url: "https://keycloak.example.com/realms/master"
|
||||
auth_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/auth"
|
||||
token_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/token"
|
||||
userinfo_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo"
|
||||
cookie_secure: true
|
||||
|
||||
cors:
|
||||
enabled: true
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
```
|
||||
|
||||
### Example 5: With Prometheus Monitoring
|
||||
|
||||
```yaml
|
||||
config: |
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
environment: "production"
|
||||
|
||||
garage:
|
||||
endpoint: "http://garage:3900"
|
||||
region: "garage"
|
||||
admin_endpoint: "http://garage:3903"
|
||||
admin_token: "YOUR_ADMIN_TOKEN"
|
||||
|
||||
auth:
|
||||
mode: "none"
|
||||
|
||||
cors:
|
||||
enabled: true
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
interval: 30s
|
||||
labels:
|
||||
prometheus: kube-prometheus
|
||||
```
|
||||
|
||||
## Accessing the Application
|
||||
|
||||
### Via Port Forward (Development)
|
||||
|
||||
```bash
|
||||
kubectl port-forward svc/my-garage-ui 8080:80
|
||||
```
|
||||
|
||||
Then visit http://localhost:8080 in your browser.
|
||||
|
||||
### Via Ingress (Production)
|
||||
|
||||
If you've enabled Ingress, access the application at the configured hostname (e.g., https://garage-ui.example.com).
|
||||
|
||||
## Upgrading
|
||||
|
||||
To upgrade the `my-garage-ui` deployment:
|
||||
|
||||
```bash
|
||||
helm upgrade my-garage-ui ./helm/garage-ui -f custom-values.yaml
|
||||
```
|
||||
|
||||
## Configuration Details
|
||||
|
||||
### Garage Endpoints
|
||||
|
||||
The application requires two Garage endpoints:
|
||||
|
||||
- **S3 API Endpoint** (`garage.endpoint`): The Garage S3 API endpoint (default port 3900)
|
||||
- **Admin API Endpoint** (`garage.admin_endpoint`): The Garage Admin API endpoint (default port 3903)
|
||||
- **Admin Token** (`garage.admin_token`): Bearer token for Admin API authentication (required)
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
The application supports three authentication modes:
|
||||
|
||||
1. **None** (`auth.mode: "none"`): No authentication required
|
||||
2. **Basic** (`auth.mode: "basic"`): HTTP Basic authentication with username/password
|
||||
3. **OIDC** (`auth.mode: "oidc"`): OpenID Connect for enterprise SSO
|
||||
|
||||
### Health Checks
|
||||
|
||||
The application exposes a health check endpoint at `/health` which is used for:
|
||||
- Kubernetes liveness probes
|
||||
- Kubernetes readiness probes
|
||||
- Manual health verification
|
||||
|
||||
### Metrics
|
||||
|
||||
When ServiceMonitor is enabled, Prometheus will scrape metrics from `/api/v1/monitoring/metrics`. This endpoint proxies metrics from the Garage Admin API.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pods not starting
|
||||
|
||||
Check if the config is valid:
|
||||
```bash
|
||||
kubectl logs -l app.kubernetes.io/name=garage-ui
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Missing or invalid `garage.admin_token`
|
||||
- Unreachable Garage endpoints
|
||||
- Invalid OIDC configuration when using `auth.mode: "oidc"`
|
||||
|
||||
### Can't access the UI
|
||||
|
||||
If using Ingress:
|
||||
```bash
|
||||
kubectl get ingress
|
||||
kubectl describe ingress <ingress-name>
|
||||
```
|
||||
|
||||
If using port-forward:
|
||||
```bash
|
||||
kubectl get pods
|
||||
kubectl port-forward <pod-name> 8080:8080
|
||||
```
|
||||
|
||||
### Configuration not updating
|
||||
|
||||
The deployment includes a checksum annotation for the ConfigMap. Changes to the config will automatically trigger a pod restart. If not:
|
||||
|
||||
```bash
|
||||
kubectl rollout restart deployment/my-garage-ui
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This Helm chart is open source and available under the same license as Garage UI.
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
- GitHub Issues: https://github.com/Noooste/garage-ui/issues
|
||||
- Garage Documentation: https://garagehq.deuxfleurs.fr/
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please submit pull requests to the Garage UI repository.
|
||||
@@ -1,27 +0,0 @@
|
||||
1. Get the application URL by running these commands:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
{{- range .paths }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- else if contains "NodePort" .Values.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "garage-ui.fullname" . }})
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo http://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "garage-ui.fullname" . }}'
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "garage-ui.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "garage-ui.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||
echo "Visit http://127.0.0.1:8080 to use your application"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||
{{- end }}
|
||||
|
||||
2. To view logs:
|
||||
kubectl logs -f deployment/{{ include "garage-ui.fullname" . }} -n {{ .Release.Namespace }}
|
||||
|
||||
For more information, see the README at https://github.com/Noooste/garage-ui/blob/main/helm/garage-ui/README.md
|
||||
@@ -1,51 +0,0 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "garage-ui.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "garage-ui.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "garage-ui.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "garage-ui.labels" -}}
|
||||
helm.sh/chart: {{ include "garage-ui.chart" . }}
|
||||
{{ include "garage-ui.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "garage-ui.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "garage-ui.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
@@ -1,9 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-config
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
data:
|
||||
config.yaml: |
|
||||
{{- .Values.config | toYaml | nindent 4 }}
|
||||
@@ -1,80 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "garage-ui.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "garage-ui.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
{{- if .Values.livenessProbe.enabled }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.livenessProbe.httpGet.path }}
|
||||
port: {{ .Values.livenessProbe.httpGet.port }}
|
||||
initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.livenessProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.livenessProbe.failureThreshold }}
|
||||
{{- end }}
|
||||
{{- if .Values.readinessProbe.enabled }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.readinessProbe.httpGet.path }}
|
||||
port: {{ .Values.readinessProbe.httpGet.port }}
|
||||
initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.readinessProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.readinessProbe.failureThreshold }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/config.yaml
|
||||
subPath: config.yaml
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: {{ include "garage-ui.fullname" . }}-config
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -1,41 +0,0 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "garage-ui.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1,26 +0,0 @@
|
||||
{{- if .Values.networkPolicy.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
{{- include "garage-ui.selectorLabels" . | nindent 6 }}
|
||||
policyTypes:
|
||||
{{- toYaml .Values.networkPolicy.policyTypes | nindent 4 }}
|
||||
ingress:
|
||||
- from: []
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
egress:
|
||||
- to: []
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
{{- end }}
|
||||
@@ -1,15 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "garage-ui.selectorLabels" . | nindent 4 }}
|
||||
@@ -1,19 +0,0 @@
|
||||
{{- if .Values.serviceMonitor.enabled -}}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceMonitor.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "garage-ui.selectorLabels" . | nindent 6 }}
|
||||
endpoints:
|
||||
- port: http
|
||||
path: {{ .Values.serviceMonitor.path }}
|
||||
interval: {{ .Values.serviceMonitor.interval }}
|
||||
{{- end }}
|
||||
@@ -1,553 +0,0 @@
|
||||
# Default values for garage-ui Helm chart
|
||||
# This file contains configuration values for deploying Garage UI
|
||||
# Customize the values below according to your deployment environment
|
||||
|
||||
# Number of replica pods to run
|
||||
# Increase for high availability (recommended: 2-3 for production)
|
||||
replicaCount: 1
|
||||
|
||||
# Docker image configuration
|
||||
image:
|
||||
# Container registry and image name
|
||||
# Default uses the official Garage UI image from Docker Hub
|
||||
repository: noooste/garage-ui
|
||||
|
||||
# Image pull policy
|
||||
# Options: Always, IfNotPresent, Never
|
||||
# IfNotPresent: Only pull if image is not already present locally
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Image tag to use (defaults to chart appVersion if empty)
|
||||
# Example: "1.0.0" or "latest"
|
||||
tag: ""
|
||||
|
||||
# Credentials for accessing private container registries
|
||||
# Example:
|
||||
# imagePullSecrets:
|
||||
# - name: regcred
|
||||
imagePullSecrets: []
|
||||
|
||||
# Override the default chart name in resource names
|
||||
# Leave empty to use the chart name
|
||||
nameOverride: ""
|
||||
|
||||
# Override the full resource name (includes release name)
|
||||
# Leave empty to use the default naming convention
|
||||
fullnameOverride: ""
|
||||
|
||||
# ============================================================================
|
||||
# APPLICATION CONFIGURATION
|
||||
# ============================================================================
|
||||
# This section contains the main application configuration
|
||||
# Customize according to your Garage deployment and security requirements
|
||||
#
|
||||
# TIP: You can now easily override specific values using --set:
|
||||
# --set config.server.port=9090
|
||||
# --set config.garage.admin_token=your-token
|
||||
# --set config.auth.mode=basic
|
||||
config:
|
||||
# ========================================
|
||||
# Server Configuration
|
||||
# ========================================
|
||||
server:
|
||||
# Network interface to bind to
|
||||
# "0.0.0.0" listens on all interfaces (recommended for containers)
|
||||
host: "0.0.0.0"
|
||||
|
||||
# Port the application listens on
|
||||
# Default: 8080
|
||||
port: 8080
|
||||
|
||||
# Deployment environment
|
||||
# Options: "production", "development", "staging"
|
||||
environment: "production"
|
||||
|
||||
# ========================================
|
||||
# Garage S3 Storage Configuration
|
||||
# ========================================
|
||||
garage:
|
||||
# Garage S3 API endpoint
|
||||
# Format: http(s)://hostname:port
|
||||
# Default port: 3900
|
||||
# Example: "http://garage.example.com:3900" or "http://garage:3900" for in-cluster
|
||||
endpoint: "http://garage:3900"
|
||||
|
||||
# S3 region name
|
||||
# For Garage, this can be any arbitrary value (Garage ignores regions)
|
||||
# Default: "garage"
|
||||
region: "garage"
|
||||
|
||||
# Garage Admin API endpoint
|
||||
# This is used for administrative operations like bucket and key management
|
||||
# Default port: 3903
|
||||
# Example: "http://garage.example.com:3903" or "http://garage:3903" for in-cluster
|
||||
admin_endpoint: "http://garage:3903"
|
||||
|
||||
# Admin API bearer token
|
||||
# REQUIRED: Obtain this from your Garage server configuration
|
||||
# This token grants administrative access - keep it secure!
|
||||
# To generate: See Garage documentation for admin token setup
|
||||
admin_token: ""
|
||||
|
||||
# ========================================
|
||||
# Authentication Configuration
|
||||
# ========================================
|
||||
auth:
|
||||
# Authentication mode
|
||||
# Options:
|
||||
# "none" - No authentication (open access - not recommended for production)
|
||||
# "basic" - Simple username/password authentication
|
||||
# "oidc" - OpenID Connect integration (recommended for production)
|
||||
mode: "none"
|
||||
|
||||
# Basic Authentication Settings
|
||||
# Only used when mode = "basic"
|
||||
# Provides simple username/password protection
|
||||
basic:
|
||||
# Username for basic auth login
|
||||
username: "admin"
|
||||
|
||||
# Password for basic auth login
|
||||
# IMPORTANT: Change this default password immediately!
|
||||
password: "changeme"
|
||||
|
||||
# OpenID Connect (OIDC) Configuration
|
||||
# Only used when mode = "oidc"
|
||||
# Integrates with identity providers like Keycloak, Auth0, Okta, etc.
|
||||
oidc:
|
||||
# Enable/disable OIDC (must be true when mode = "oidc")
|
||||
enabled: false
|
||||
|
||||
# Display name of your OIDC provider
|
||||
# Examples: "Keycloak", "Auth0", "Okta", "Azure AD"
|
||||
provider_name: "Keycloak"
|
||||
|
||||
# OAuth2 client ID registered with your OIDC provider
|
||||
# Obtain this from your OIDC provider's application settings
|
||||
client_id: "garage-ui"
|
||||
|
||||
# OAuth2 client secret registered with your OIDC provider
|
||||
# IMPORTANT: Keep this secret secure! Consider using Kubernetes secrets
|
||||
client_secret: "your-client-secret"
|
||||
|
||||
# OAuth2/OIDC scopes to request during authentication
|
||||
# Standard scopes: openid (required), email, profile
|
||||
# Add custom scopes as needed by your provider
|
||||
scopes:
|
||||
- openid
|
||||
- email
|
||||
- profile
|
||||
|
||||
# OIDC Provider Endpoints
|
||||
# Replace "keycloak.example.com" and realm with your actual values
|
||||
# For Keycloak: https://your-keycloak/realms/your-realm
|
||||
# For Auth0: https://your-tenant.auth0.com
|
||||
# For Okta: https://your-domain.okta.com
|
||||
|
||||
# OIDC issuer URL (base URL for OIDC discovery)
|
||||
issuer_url: "https://keycloak.example.com/realms/master"
|
||||
|
||||
# Authorization endpoint URL
|
||||
auth_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/auth"
|
||||
|
||||
# Token endpoint URL
|
||||
token_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/token"
|
||||
|
||||
# User info endpoint URL
|
||||
userinfo_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo"
|
||||
|
||||
# Token Validation Settings
|
||||
# Set to true only if you have issuer mismatch issues (not recommended)
|
||||
skip_issuer_check: false
|
||||
|
||||
# Set to true to skip token expiry validation (not recommended for production)
|
||||
skip_expiry_check: false
|
||||
|
||||
# User Attribute Mappings
|
||||
# Map OIDC claims to application user attributes
|
||||
# Adjust these based on your OIDC provider's claim structure
|
||||
|
||||
# Claim containing user's email address
|
||||
email_attribute: "email"
|
||||
|
||||
# Claim containing user's username/login ID
|
||||
username_attribute: "preferred_username"
|
||||
|
||||
# Claim containing user's display name
|
||||
name_attribute: "name"
|
||||
|
||||
# Role-Based Access Control (Optional)
|
||||
# Path to roles in the OIDC token claims
|
||||
# Example for Keycloak: "resource_access.garage-ui.roles"
|
||||
# Example for Auth0: "https://your-app/roles"
|
||||
role_attribute_path: "resource_access.garage-ui.roles"
|
||||
|
||||
# Role name that grants admin privileges
|
||||
# Users with this role will have full administrative access
|
||||
admin_role: "admin"
|
||||
|
||||
# TLS/SSL Configuration
|
||||
# Skip TLS certificate verification (only for testing - never in production!)
|
||||
tls_skip_verify: false
|
||||
|
||||
# Session Management
|
||||
# How long user sessions remain valid without activity
|
||||
# Value in seconds (86400 = 24 hours)
|
||||
session_max_age: 86400
|
||||
|
||||
# Name of the session cookie
|
||||
cookie_name: "garage_session"
|
||||
|
||||
# Only send cookie over HTTPS connections
|
||||
# Set to false only for local development without HTTPS
|
||||
cookie_secure: true
|
||||
|
||||
# Prevent JavaScript access to the cookie (security feature)
|
||||
# Should remain true for security
|
||||
cookie_http_only: true
|
||||
|
||||
# SameSite cookie attribute for CSRF protection
|
||||
# Options: "lax" (recommended), "strict", "none"
|
||||
# Use "lax" for most cases, "strict" for maximum security
|
||||
cookie_same_site: "lax"
|
||||
|
||||
# ========================================
|
||||
# CORS (Cross-Origin Resource Sharing)
|
||||
# ========================================
|
||||
# Configure which origins can access the API from browsers
|
||||
cors:
|
||||
# Enable or disable CORS
|
||||
# Disable if the frontend and backend are served from the same origin
|
||||
enabled: true
|
||||
|
||||
# List of allowed origins
|
||||
# "*" allows all origins (convenient but less secure)
|
||||
# For production, specify exact origins:
|
||||
# Example:
|
||||
# - "https://garage-ui.example.com"
|
||||
# - "https://app.example.com"
|
||||
allowed_origins:
|
||||
- "*"
|
||||
|
||||
# HTTP methods that are allowed in CORS requests
|
||||
# Include all methods your API uses
|
||||
allowed_methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
|
||||
# HTTP headers that are allowed in CORS requests
|
||||
# Add any custom headers your application requires
|
||||
allowed_headers:
|
||||
- Origin
|
||||
- Content-Type
|
||||
- Accept
|
||||
- Authorization
|
||||
|
||||
# Allow credentials (cookies, authorization headers) in CORS requests
|
||||
# Set to true if your frontend needs to send authentication cookies
|
||||
# NOTE: When true, allowed_origins cannot be "*"
|
||||
allow_credentials: false
|
||||
|
||||
# How long browsers can cache CORS preflight responses (in seconds)
|
||||
# 3600 = 1 hour
|
||||
max_age: 3600
|
||||
|
||||
# ========================================
|
||||
# Logging Configuration
|
||||
# ========================================
|
||||
logging:
|
||||
# Log verbosity level
|
||||
# Options:
|
||||
# "debug" - Verbose logging, useful for troubleshooting
|
||||
# "info" - Standard logging (recommended for production)
|
||||
# "warn" - Only warnings and errors
|
||||
# "error" - Only errors
|
||||
level: "info"
|
||||
|
||||
# Log output format
|
||||
# Options:
|
||||
# "json" - Structured JSON format (recommended for production/log aggregation)
|
||||
# "text" - Human-readable text format (useful for development)
|
||||
format: "json"
|
||||
|
||||
# ============================================================================
|
||||
# KUBERNETES POD CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
# Annotations to add to the pod
|
||||
# Use for integrations with service meshes, monitoring, etc.
|
||||
# Example:
|
||||
# podAnnotations:
|
||||
# prometheus.io/scrape: "true"
|
||||
# prometheus.io/port: "8080"
|
||||
podAnnotations: {}
|
||||
|
||||
# Pod-level security context
|
||||
# Defines security settings for all containers in the pod
|
||||
podSecurityContext:
|
||||
# Run containers as non-root user (security best practice)
|
||||
runAsNonRoot: true
|
||||
|
||||
# User ID to run containers as
|
||||
# Default: 1000 (non-privileged user)
|
||||
runAsUser: 1000
|
||||
|
||||
# Group ID for filesystem access
|
||||
# Files created by the pod will have this group ownership
|
||||
fsGroup: 1000
|
||||
|
||||
# Container-level security context
|
||||
# Security settings specific to the application container
|
||||
securityContext:
|
||||
# Prevent privilege escalation (security best practice)
|
||||
# Ensures the container cannot gain more privileges than its parent
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
# Drop all Linux capabilities and only add what's needed
|
||||
# This follows the principle of least privilege
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
# Allow write access to the root filesystem
|
||||
# Set to true for read-only root filesystem (more secure but may require adjustments)
|
||||
readOnlyRootFilesystem: false
|
||||
|
||||
# ============================================================================
|
||||
# KUBERNETES SERVICE
|
||||
# ============================================================================
|
||||
service:
|
||||
# Service type determines how the service is exposed
|
||||
# Options:
|
||||
# ClusterIP - Internal only (default, recommended when using Ingress)
|
||||
# NodePort - Expose on each node's IP at a static port
|
||||
# LoadBalancer - Expose using a cloud provider's load balancer
|
||||
type: ClusterIP
|
||||
|
||||
# Port the service listens on
|
||||
# This is the port other services/ingress will connect to
|
||||
# Default: 80
|
||||
port: 80
|
||||
|
||||
# ============================================================================
|
||||
# INGRESS CONFIGURATION
|
||||
# ============================================================================
|
||||
# Ingress exposes HTTP/HTTPS routes from outside the cluster to the service
|
||||
ingress:
|
||||
# Enable or disable ingress
|
||||
# Set to true to make the application accessible from outside the cluster
|
||||
enabled: false
|
||||
|
||||
# Ingress class name
|
||||
# Specifies which ingress controller to use
|
||||
# Common values: "nginx", "traefik", "alb" (AWS)
|
||||
# Ensure the specified controller is installed in your cluster
|
||||
className: "nginx"
|
||||
|
||||
# Additional annotations for the ingress
|
||||
# Use for configuring ingress controller behavior, SSL, authentication, etc.
|
||||
# Examples:
|
||||
# cert-manager.io/cluster-issuer: "letsencrypt-prod" # For automatic SSL certificates
|
||||
# nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
# nginx.ingress.kubernetes.io/proxy-body-size: "100m"
|
||||
annotations: {}
|
||||
|
||||
# Hostname and path configuration
|
||||
hosts:
|
||||
# Replace "garage-ui.local" with your actual domain
|
||||
- host: garage-ui.local
|
||||
paths:
|
||||
# Path where the application will be accessible
|
||||
- path: /
|
||||
# pathType options: Prefix, Exact, ImplementationSpecific
|
||||
pathType: Prefix
|
||||
|
||||
# TLS/SSL configuration
|
||||
# Uncomment and configure to enable HTTPS
|
||||
# Requires a TLS certificate stored in a Kubernetes secret
|
||||
tls: []
|
||||
# - secretName: garage-ui-tls
|
||||
# hosts:
|
||||
# - garage-ui.local
|
||||
#
|
||||
# To use cert-manager for automatic certificates:
|
||||
# 1. Install cert-manager in your cluster
|
||||
# 2. Create a ClusterIssuer (e.g., letsencrypt-prod)
|
||||
# 3. Add annotation: cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
# 4. Uncomment the tls section above
|
||||
|
||||
# ============================================================================
|
||||
# RESOURCE LIMITS AND REQUESTS
|
||||
# ============================================================================
|
||||
# Configure CPU and memory allocation for the pods
|
||||
# Requests: Guaranteed resources (used for scheduling)
|
||||
# Limits: Maximum resources the container can use
|
||||
resources:
|
||||
limits:
|
||||
# Maximum CPU cores the container can use
|
||||
# 500m = 0.5 CPU cores
|
||||
# Increase for high-traffic deployments
|
||||
cpu: 500m
|
||||
|
||||
# Maximum memory the container can use
|
||||
# Container will be killed if it exceeds this limit
|
||||
memory: 512Mi
|
||||
|
||||
requests:
|
||||
# Guaranteed CPU allocated to the container
|
||||
# 100m = 0.1 CPU cores
|
||||
# Cluster scheduler ensures this amount is available
|
||||
cpu: 100m
|
||||
|
||||
# Guaranteed memory allocated to the container
|
||||
# Cluster scheduler ensures this amount is available
|
||||
memory: 128Mi
|
||||
|
||||
# ============================================================================
|
||||
# HEALTH CHECKS
|
||||
# ============================================================================
|
||||
|
||||
# Liveness Probe
|
||||
# Kubernetes restarts the container if this probe fails
|
||||
# Detects if the application is running but in a broken state
|
||||
livenessProbe:
|
||||
# Enable or disable the liveness probe
|
||||
enabled: true
|
||||
|
||||
# HTTP endpoint to check
|
||||
httpGet:
|
||||
# Health check endpoint path
|
||||
path: /health
|
||||
# Port name (defined in the container spec)
|
||||
port: http
|
||||
|
||||
# Wait time before starting liveness checks after container starts
|
||||
# Give the application time to initialize
|
||||
initialDelaySeconds: 30
|
||||
|
||||
# How often to perform the probe (in seconds)
|
||||
periodSeconds: 10
|
||||
|
||||
# Maximum time to wait for the probe to complete
|
||||
timeoutSeconds: 3
|
||||
|
||||
# Number of consecutive failures before restarting the container
|
||||
failureThreshold: 3
|
||||
|
||||
# Readiness Probe
|
||||
# Kubernetes stops sending traffic if this probe fails
|
||||
# Determines when the container is ready to accept traffic
|
||||
readinessProbe:
|
||||
# Enable or disable the readiness probe
|
||||
enabled: true
|
||||
|
||||
# HTTP endpoint to check
|
||||
httpGet:
|
||||
# Readiness check endpoint path
|
||||
path: /health
|
||||
# Port name (defined in the container spec)
|
||||
port: http
|
||||
|
||||
# Wait time before starting readiness checks after container starts
|
||||
initialDelaySeconds: 10
|
||||
|
||||
# How often to perform the probe (in seconds)
|
||||
periodSeconds: 5
|
||||
|
||||
# Maximum time to wait for the probe to complete
|
||||
timeoutSeconds: 3
|
||||
|
||||
# Number of consecutive failures before marking as not ready
|
||||
failureThreshold: 3
|
||||
|
||||
# ============================================================================
|
||||
# MONITORING AND OBSERVABILITY
|
||||
# ============================================================================
|
||||
|
||||
# ServiceMonitor for Prometheus Operator
|
||||
# Automatically configure Prometheus to scrape metrics from the application
|
||||
# Requires Prometheus Operator to be installed in the cluster
|
||||
serviceMonitor:
|
||||
# Enable or disable ServiceMonitor creation
|
||||
# Set to true if using Prometheus Operator for monitoring
|
||||
enabled: false
|
||||
|
||||
# How often Prometheus should scrape metrics
|
||||
# Format: duration string (e.g., "30s", "1m", "5m")
|
||||
interval: 30s
|
||||
|
||||
# Metrics endpoint path
|
||||
# The application exposes metrics at this path
|
||||
path: /api/v1/monitoring/metrics
|
||||
|
||||
# Additional labels for the ServiceMonitor
|
||||
# Use to match Prometheus scrape configurations
|
||||
# Example:
|
||||
# labels:
|
||||
# prometheus: kube-prometheus
|
||||
labels: {}
|
||||
|
||||
# ============================================================================
|
||||
# NETWORK POLICY
|
||||
# ============================================================================
|
||||
# Controls network traffic to/from the pods
|
||||
# Requires a network plugin that supports NetworkPolicy (e.g., Calico, Cilium)
|
||||
networkPolicy:
|
||||
# Enable or disable NetworkPolicy creation
|
||||
# Provides network-level security by restricting pod communication
|
||||
enabled: false
|
||||
|
||||
# Types of policies to enforce
|
||||
# Ingress: Controls incoming traffic to the pod
|
||||
# Egress: Controls outgoing traffic from the pod
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
|
||||
# Note: When enabled, by default only allows necessary traffic
|
||||
# Customize the NetworkPolicy template if you need specific rules
|
||||
|
||||
# ============================================================================
|
||||
# POD SCHEDULING
|
||||
# ============================================================================
|
||||
|
||||
# Node Selector
|
||||
# Schedule pods only on nodes with specific labels
|
||||
# Useful for deploying to specific node pools or hardware types
|
||||
# Example:
|
||||
# nodeSelector:
|
||||
# disktype: ssd
|
||||
# environment: production
|
||||
nodeSelector: {}
|
||||
|
||||
# Tolerations
|
||||
# Allow pods to be scheduled on nodes with matching taints
|
||||
# Useful for dedicated node pools or special hardware
|
||||
# Example:
|
||||
# tolerations:
|
||||
# - key: "dedicated"
|
||||
# operator: "Equal"
|
||||
# value: "garage-ui"
|
||||
# effect: "NoSchedule"
|
||||
tolerations: []
|
||||
|
||||
# Affinity Rules
|
||||
# Advanced pod scheduling constraints
|
||||
# Controls which nodes pods can be scheduled on and pod co-location
|
||||
# Example for pod anti-affinity (spread pods across nodes):
|
||||
# affinity:
|
||||
# podAntiAffinity:
|
||||
# preferredDuringSchedulingIgnoredDuringExecution:
|
||||
# - weight: 100
|
||||
# podAffinityTerm:
|
||||
# labelSelector:
|
||||
# matchExpressions:
|
||||
# - key: app.kubernetes.io/name
|
||||
# operator: In
|
||||
# values:
|
||||
# - garage-ui
|
||||
# topologyKey: kubernetes.io/hostname
|
||||
affinity: {}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Garage UI Helm Charts</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Garage UI Helm Charts Repository</h1>
|
||||
<p>This repository hosts Helm charts for Garage UI.</p>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user