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

Garage UI Helm Charts Repository

+

This repository hosts Helm charts for Garage UI.

+ + \ No newline at end of file