From 30804c85ad34b456e4d3caa6fa8a98126add6a55 Mon Sep 17 00:00:00 2001 From: Noste <83548733+Noooste@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:51:18 +0100 Subject: [PATCH] Initial gh-pages --- .dockerignore | 46 ++ .github/workflows/build.yml | 52 ++ .github/workflows/release.yml | 4 + .gitignore | 62 +++ Dockerfile | 49 ++ LICENSE | 21 + backend/go.mod | 84 +++ backend/go.sum | 239 ++++++++ backend/internal/auth/auth.go | 339 ++++++++++++ backend/internal/auth/jwt.go | 163 ++++++ backend/internal/config/config.go | 252 +++++++++ backend/internal/handlers/buckets.go | 316 +++++++++++ backend/internal/handlers/cluster.go | 145 +++++ backend/internal/handlers/health.go | 40 ++ backend/internal/handlers/monitoring.go | 145 +++++ backend/internal/handlers/objects.go | 541 ++++++++++++++++++ backend/internal/handlers/users.go | 341 ++++++++++++ backend/internal/middleware/auth.go | 133 +++++ backend/internal/middleware/cors.go | 65 +++ backend/internal/models/garage_admin.go | 263 +++++++++ backend/internal/models/requests.go | 61 ++ backend/internal/models/responses.go | 204 +++++++ backend/internal/routes/routes.go | 246 +++++++++ backend/internal/services/garage_admin.go | 460 +++++++++++++++ backend/internal/services/s3.go | 500 +++++++++++++++++ backend/main.go | 172 ++++++ backend/pkg/logger/logger.go | 121 ++++ backend/pkg/utils/cache.go | 95 ++++ config.yaml.example | 92 +++ docker-compose.yml | 39 ++ helm/garage-ui/.helmignore | 23 + helm/garage-ui/Chart.yaml | 19 + helm/garage-ui/README.md | 396 +++++++++++++ helm/garage-ui/templates/NOTES.txt | 27 + helm/garage-ui/templates/_helpers.tpl | 51 ++ helm/garage-ui/templates/configmap.yaml | 9 + helm/garage-ui/templates/deployment.yaml | 80 +++ helm/garage-ui/templates/ingress.yaml | 41 ++ helm/garage-ui/templates/networkpolicy.yaml | 26 + helm/garage-ui/templates/service.yaml | 15 + helm/garage-ui/templates/servicemonitor.yaml | 19 + helm/garage-ui/values.yaml | 553 +++++++++++++++++++ index.html | 0 43 files changed, 6549 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/auth/auth.go create mode 100644 backend/internal/auth/jwt.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/handlers/buckets.go create mode 100644 backend/internal/handlers/cluster.go create mode 100644 backend/internal/handlers/health.go create mode 100644 backend/internal/handlers/monitoring.go create mode 100644 backend/internal/handlers/objects.go create mode 100644 backend/internal/handlers/users.go create mode 100644 backend/internal/middleware/auth.go create mode 100644 backend/internal/middleware/cors.go create mode 100644 backend/internal/models/garage_admin.go create mode 100644 backend/internal/models/requests.go create mode 100644 backend/internal/models/responses.go create mode 100644 backend/internal/routes/routes.go create mode 100644 backend/internal/services/garage_admin.go create mode 100644 backend/internal/services/s3.go create mode 100644 backend/main.go create mode 100644 backend/pkg/logger/logger.go create mode 100644 backend/pkg/utils/cache.go create mode 100644 config.yaml.example create mode 100644 docker-compose.yml create mode 100644 helm/garage-ui/.helmignore create mode 100644 helm/garage-ui/Chart.yaml create mode 100644 helm/garage-ui/README.md create mode 100644 helm/garage-ui/templates/NOTES.txt create mode 100644 helm/garage-ui/templates/_helpers.tpl create mode 100644 helm/garage-ui/templates/configmap.yaml create mode 100644 helm/garage-ui/templates/deployment.yaml create mode 100644 helm/garage-ui/templates/ingress.yaml create mode 100644 helm/garage-ui/templates/networkpolicy.yaml create mode 100644 helm/garage-ui/templates/service.yaml create mode 100644 helm/garage-ui/templates/servicemonitor.yaml create mode 100644 helm/garage-ui/values.yaml create mode 100644 index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dfcf4d1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,46 @@ + +# 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 + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..35c8d78 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,52 @@ +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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..575477c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,4 @@ +name: release.yml +on: + +jobs: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b82b1be --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# 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/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7aa3002 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +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"] + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d96f845 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +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. diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..e3a2942 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,84 @@ +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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..8cff3c6 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,239 @@ +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= diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go new file mode 100644 index 0000000..01592e9 --- /dev/null +++ b/backend/internal/auth/auth.go @@ -0,0 +1,339 @@ +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 +} diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..63c84b5 --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -0,0 +1,163 @@ +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 +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..8c6d397 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,252 @@ +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" +} diff --git a/backend/internal/handlers/buckets.go b/backend/internal/handlers/buckets.go new file mode 100644 index 0000000..425eb79 --- /dev/null +++ b/backend/internal/handlers/buckets.go @@ -0,0 +1,316 @@ +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)) +} diff --git a/backend/internal/handlers/cluster.go b/backend/internal/handlers/cluster.go new file mode 100644 index 0000000..3ea6371 --- /dev/null +++ b/backend/internal/handlers/cluster.go @@ -0,0 +1,145 @@ +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)) +} diff --git a/backend/internal/handlers/health.go b/backend/internal/handlers/health.go new file mode 100644 index 0000000..323ec08 --- /dev/null +++ b/backend/internal/handlers/health.go @@ -0,0 +1,40 @@ +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)) +} diff --git a/backend/internal/handlers/monitoring.go b/backend/internal/handlers/monitoring.go new file mode 100644 index 0000000..4f793ae --- /dev/null +++ b/backend/internal/handlers/monitoring.go @@ -0,0 +1,145 @@ +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)) +} diff --git a/backend/internal/handlers/objects.go b/backend/internal/handlers/objects.go new file mode 100644 index 0000000..b2414da --- /dev/null +++ b/backend/internal/handlers/objects.go @@ -0,0 +1,541 @@ +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)) +} diff --git a/backend/internal/handlers/users.go b/backend/internal/handlers/users.go new file mode 100644 index 0000000..0f4babf --- /dev/null +++ b/backend/internal/handlers/users.go @@ -0,0 +1,341 @@ +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)) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..b30413d --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,133 @@ +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() + } +} diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go new file mode 100644 index 0000000..bac15f2 --- /dev/null +++ b/backend/internal/middleware/cors.go @@ -0,0 +1,65 @@ +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 +} diff --git a/backend/internal/models/garage_admin.go b/backend/internal/models/garage_admin.go new file mode 100644 index 0000000..7ca63fd --- /dev/null +++ b/backend/internal/models/garage_admin.go @@ -0,0 +1,263 @@ +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"` +} diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go new file mode 100644 index 0000000..ed876a2 --- /dev/null +++ b/backend/internal/models/requests.go @@ -0,0 +1,61 @@ +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 +} diff --git a/backend/internal/models/responses.go b/backend/internal/models/responses.go new file mode 100644 index 0000000..bf9f513 --- /dev/null +++ b/backend/internal/models/responses.go @@ -0,0 +1,204 @@ +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" +) diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go new file mode 100644 index 0000000..1657694 --- /dev/null +++ b/backend/internal/routes/routes.go @@ -0,0 +1,246 @@ +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) + }) + } +} diff --git a/backend/internal/services/garage_admin.go b/backend/internal/services/garage_admin.go new file mode 100644 index 0000000..1e8f44b --- /dev/null +++ b/backend/internal/services/garage_admin.go @@ -0,0 +1,460 @@ +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 +} diff --git a/backend/internal/services/s3.go b/backend/internal/services/s3.go new file mode 100644 index 0000000..9a5edd8 --- /dev/null +++ b/backend/internal/services/s3.go @@ -0,0 +1,500 @@ +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 +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..cb2352f --- /dev/null +++ b/backend/main.go @@ -0,0 +1,172 @@ +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(), + }, + }) +} diff --git a/backend/pkg/logger/logger.go b/backend/pkg/logger/logger.go new file mode 100644 index 0000000..7d22748 --- /dev/null +++ b/backend/pkg/logger/logger.go @@ -0,0 +1,121 @@ +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) +} diff --git a/backend/pkg/utils/cache.go b/backend/pkg/utils/cache.go new file mode 100644 index 0000000..5413ea0 --- /dev/null +++ b/backend/pkg/utils/cache.go @@ -0,0 +1,95 @@ +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() diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..9b805ed --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,92 @@ +# 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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2f6ade4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +--- +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" diff --git a/helm/garage-ui/.helmignore b/helm/garage-ui/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/helm/garage-ui/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/helm/garage-ui/Chart.yaml b/helm/garage-ui/Chart.yaml new file mode 100644 index 0000000..7a20237 --- /dev/null +++ b/helm/garage-ui/Chart.yaml @@ -0,0 +1,19 @@ +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" diff --git a/helm/garage-ui/README.md b/helm/garage-ui/README.md new file mode 100644 index 0000000..80632c9 --- /dev/null +++ b/helm/garage-ui/README.md @@ -0,0 +1,396 @@ +# 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 +``` + +If using port-forward: +```bash +kubectl get pods +kubectl port-forward 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. diff --git a/helm/garage-ui/templates/NOTES.txt b/helm/garage-ui/templates/NOTES.txt new file mode 100644 index 0000000..6e78df4 --- /dev/null +++ b/helm/garage-ui/templates/NOTES.txt @@ -0,0 +1,27 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "garage-ui.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "garage-ui.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "garage-ui.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "garage-ui.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} + +2. To view logs: + kubectl logs -f deployment/{{ include "garage-ui.fullname" . }} -n {{ .Release.Namespace }} + +For more information, see the README at https://github.com/Noooste/garage-ui/blob/main/helm/garage-ui/README.md diff --git a/helm/garage-ui/templates/_helpers.tpl b/helm/garage-ui/templates/_helpers.tpl new file mode 100644 index 0000000..453d1b9 --- /dev/null +++ b/helm/garage-ui/templates/_helpers.tpl @@ -0,0 +1,51 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "garage-ui.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "garage-ui.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "garage-ui.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "garage-ui.labels" -}} +helm.sh/chart: {{ include "garage-ui.chart" . }} +{{ include "garage-ui.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "garage-ui.selectorLabels" -}} +app.kubernetes.io/name: {{ include "garage-ui.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/helm/garage-ui/templates/configmap.yaml b/helm/garage-ui/templates/configmap.yaml new file mode 100644 index 0000000..a6fbc29 --- /dev/null +++ b/helm/garage-ui/templates/configmap.yaml @@ -0,0 +1,9 @@ +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 }} diff --git a/helm/garage-ui/templates/deployment.yaml b/helm/garage-ui/templates/deployment.yaml new file mode 100644 index 0000000..138406b --- /dev/null +++ b/helm/garage-ui/templates/deployment.yaml @@ -0,0 +1,80 @@ +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 }} diff --git a/helm/garage-ui/templates/ingress.yaml b/helm/garage-ui/templates/ingress.yaml new file mode 100644 index 0000000..92773ff --- /dev/null +++ b/helm/garage-ui/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "garage-ui.fullname" . }} + labels: + {{- include "garage-ui.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "garage-ui.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm/garage-ui/templates/networkpolicy.yaml b/helm/garage-ui/templates/networkpolicy.yaml new file mode 100644 index 0000000..412a8db --- /dev/null +++ b/helm/garage-ui/templates/networkpolicy.yaml @@ -0,0 +1,26 @@ +{{- if .Values.networkPolicy.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "garage-ui.fullname" . }} + labels: + {{- include "garage-ui.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "garage-ui.selectorLabels" . | nindent 6 }} + policyTypes: + {{- toYaml .Values.networkPolicy.policyTypes | nindent 4 }} + ingress: + - from: [] + ports: + - protocol: TCP + port: 8080 + egress: + - to: [] + ports: + - protocol: TCP + port: 53 + - protocol: UDP + port: 53 +{{- end }} diff --git a/helm/garage-ui/templates/service.yaml b/helm/garage-ui/templates/service.yaml new file mode 100644 index 0000000..aa39b5c --- /dev/null +++ b/helm/garage-ui/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "garage-ui.fullname" . }} + labels: + {{- include "garage-ui.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "garage-ui.selectorLabels" . | nindent 4 }} diff --git a/helm/garage-ui/templates/servicemonitor.yaml b/helm/garage-ui/templates/servicemonitor.yaml new file mode 100644 index 0000000..3a2ef11 --- /dev/null +++ b/helm/garage-ui/templates/servicemonitor.yaml @@ -0,0 +1,19 @@ +{{- if .Values.serviceMonitor.enabled -}} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "garage-ui.fullname" . }} + labels: + {{- include "garage-ui.labels" . | nindent 4 }} + {{- with .Values.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + {{- include "garage-ui.selectorLabels" . | nindent 6 }} + endpoints: + - port: http + path: {{ .Values.serviceMonitor.path }} + interval: {{ .Values.serviceMonitor.interval }} +{{- end }} diff --git a/helm/garage-ui/values.yaml b/helm/garage-ui/values.yaml new file mode 100644 index 0000000..ede94e3 --- /dev/null +++ b/helm/garage-ui/values.yaml @@ -0,0 +1,553 @@ +# 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: {} diff --git a/index.html b/index.html new file mode 100644 index 0000000..e69de29