diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dfcf4d1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,46 @@ + +# Git files +.git +.gitignore + +# IDE files +.idea +.vscode +*.swp +*.swo +*~ + +# Build artifacts +bin/ +*.exe +*.dll +*.so +*.dylib + +# Test files +*.test +coverage.txt +coverage.html + +# Frontend development files +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ +frontend/.env +frontend/.env.local + +# Backend development files +*.log +tmp/ +temp/ + +# Documentation (optional, include if needed in image) +# docs/ + +# OS files +.DS_Store +Thumbs.db + +# Development config (you may want a separate config for production) +*.local.yaml + diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..e82c285 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,52 @@ +name: Docker Build and Push + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_LOGIN }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_REGISTRY }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 + + - name: Image digest + run: echo ${{ steps.meta.outputs.digest }} diff --git a/.gitignore b/.gitignore index 853bdb7..b82b1be 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,4 @@ data/ meta/ garage.toml -docs/ \ No newline at end of file +backend/docs/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ec16f8c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +FROM node:25-alpine3.22 AS frontend-builder + +WORKDIR /app/frontend + +COPY frontend/package.json frontend/package-lock.json* ./ + +RUN npm ci + +COPY frontend/ . + +COPY frontend/.env.production .env + +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/go.mod b/backend/go.mod similarity index 54% rename from go.mod rename to backend/go.mod index 19d763b..70347c1 100644 --- a/go.mod +++ b/backend/go.mod @@ -3,13 +3,12 @@ module Noooste/garage-ui go 1.25.3 require ( - github.com/Noooste/azuretls-client v1.12.9 + github.com/Noooste/azuretls-client v1.12.10 github.com/Noooste/swagger v1.2.0 - github.com/aws/aws-sdk-go-v2 v1.30.4 - github.com/aws/aws-sdk-go-v2/credentials v1.17.28 - github.com/aws/aws-sdk-go-v2/service/s3 v1.59.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 @@ -19,67 +18,66 @@ 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.1 // 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/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 // indirect - github.com/aws/smithy-go v1.23.2 // 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.3 // indirect - github.com/go-openapi/swag/jsonname v0.25.3 // indirect - github.com/go-openapi/swag/jsonutils v0.25.3 // indirect - github.com/go-openapi/swag/loading v0.25.3 // indirect - github.com/go-openapi/swag/stringutils v0.25.3 // indirect - github.com/go-openapi/swag/typeutils v0.25.3 // indirect - github.com/go-openapi/swag/yamlutils v0.25.3 // 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.2 // indirect + github.com/gofiber/utils/v2 v2.0.0-rc.3 // indirect github.com/google/gopacket v1.1.19 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/klauspost/compress v1.18.1 // 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.5.1 // indirect - github.com/refraction-networking/utls v1.8.0 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // 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.5.0 // 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.44.0 // 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/go.sum b/backend/go.sum similarity index 66% rename from go.sum rename to backend/go.sum index a06d0f6..8df8bac 100644 --- a/go.sum +++ b/backend/go.sum @@ -2,6 +2,8 @@ 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= @@ -10,59 +12,25 @@ 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/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8= -github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0= -github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= -github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= -github.com/aws/aws-sdk-go-v2/credentials v1.17.28 h1:m8+AHY/ND8CMHJnPoH7PJIRakWGa4gbfbxuY9TGTUXM= -github.com/aws/aws-sdk-go-v2/credentials v1.17.28/go.mod h1:6TF7dSc78ehD1SL6KpRIPKMA1GyyWflIkjqg+qmf4+c= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 h1:TNyt/+X43KJ9IJJMjKfa3bNTiZbUP7DeCxfbTROESwY= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 h1:mimdLQkIX1zr8GIPY1ZtALdBQGxcASiBd2MOp8m/dMc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16/go.mod h1:YHk6owoSwrIsok+cAH9PENCOGoH5PU2EllX4vLtSrsY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 h1:GckUnpm4EJOAio1c8o25a+b3lVfwVzC9gnSBqiiNmZM= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18/go.mod h1:Br6+bxfG33Dk3ynmkhsW2Z/t9D4+lRqdLDNCKi85w0U= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 h1:tJ5RnkHCiSH0jyd6gROjlJtNwov0eGYNz8s8nFcR0jQ= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18/go.mod h1:++NHzT+nAF7ZPrHPsA+ENvsXkOO8wEu+C6RXltAG4/c= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 h1:jg16PhLPUiHIj8zYIW6bqzeQSuHVEiWnGA0Brz5Xv2I= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16/go.mod h1:Uyk1zE1VVdsHSU7096h/rwnXDzOzYQVl+FNPhPw7ShY= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w= -github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0 h1:Cso4Ev/XauMVsbwdhYEoxg8rxZWw43CFqqaPB5w3W2c= -github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0/go.mod h1:BSPI0EfnYUuNHPS0uqIo5VrRwzie+Fp+YhQOUs16sKI= -github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= -github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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= @@ -77,6 +45,8 @@ github.com/gaukas/clienthellod v0.4.2 h1:LPJ+LSeqt99pqeCV4C0cllk+pyWmERisP7w6qWr 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= @@ -88,22 +58,22 @@ github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4Z 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.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E= -github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU= -github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A= -github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= -github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw= -github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao= -github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y= -github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c= -github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w= -github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= -github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc= -github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= -github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg= -github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao= +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= @@ -112,12 +82,19 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v 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/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= @@ -126,19 +103,39 @@ github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+H github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 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= @@ -147,20 +144,23 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 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.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/refraction-networking/utls v1.8.0 h1:L38krhiTAyj9EeiQQa2sg+hYb4qwLCqdMcpZrRfbONE= -github.com/refraction-networking/utls v1.8.0/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +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/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +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/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -169,7 +169,6 @@ 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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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= @@ -180,6 +179,8 @@ 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= @@ -196,8 +197,8 @@ 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.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +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= @@ -215,7 +216,10 @@ 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= @@ -226,8 +230,8 @@ 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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +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.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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/internal/auth/auth.go b/backend/internal/auth/auth.go similarity index 100% rename from internal/auth/auth.go rename to backend/internal/auth/auth.go diff --git a/internal/config/config.go b/backend/internal/config/config.go similarity index 57% rename from internal/config/config.go rename to backend/internal/config/config.go index bbe2c52..8c6d397 100644 --- a/internal/config/config.go +++ b/backend/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "strings" "github.com/spf13/viper" ) @@ -18,17 +19,16 @@ type Config struct { // ServerConfig contains server-related configuration type ServerConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Environment string `mapstructure:"environment"` + 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"` - AccessKey string `mapstructure:"access_key"` - SecretKey string `mapstructure:"secret_key"` UseSSL bool `mapstructure:"use_ssl"` ForcePathStyle bool `mapstructure:"force_path_style"` AdminEndpoint string `mapstructure:"admin_endpoint"` @@ -97,27 +97,31 @@ func Load(configPath string) (*Config, error) { configPath = "config.yaml" } - // Check if config file exists - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return nil, fmt.Errorf("config file not found: %s", configPath) - } - // 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(".", "_")) - // Read the configuration file - if err := viper.ReadInConfig(); err != nil { - return nil, fmt.Errorf("failed to read config file: %w", err) + // 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 configuration into our Config struct + // Unmarshal the config into the Config struct var cfg Config if err := viper.Unmarshal(&cfg); err != nil { - return nil, fmt.Errorf("failed to unmarshal config: %w", err) + return nil, fmt.Errorf("error unmarshaling config: %w", err) } // Validate the configuration @@ -128,6 +132,64 @@ func Load(configPath string) (*Config, error) { 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 @@ -139,12 +201,6 @@ func (c *Config) Validate() error { if c.Garage.Endpoint == "" { return fmt.Errorf("garage endpoint is required") } - if c.Garage.AccessKey == "" { - return fmt.Errorf("garage access_key is required") - } - if c.Garage.SecretKey == "" { - return fmt.Errorf("garage secret_key is required") - } if c.Garage.AdminEndpoint == "" { return fmt.Errorf("garage admin_endpoint is required") } diff --git a/internal/handlers/buckets.go b/backend/internal/handlers/buckets.go similarity index 100% rename from internal/handlers/buckets.go rename to backend/internal/handlers/buckets.go diff --git a/internal/handlers/cluster.go b/backend/internal/handlers/cluster.go similarity index 100% rename from internal/handlers/cluster.go rename to backend/internal/handlers/cluster.go diff --git a/internal/handlers/health.go b/backend/internal/handlers/health.go similarity index 100% rename from internal/handlers/health.go rename to backend/internal/handlers/health.go diff --git a/internal/handlers/monitoring.go b/backend/internal/handlers/monitoring.go similarity index 100% rename from internal/handlers/monitoring.go rename to backend/internal/handlers/monitoring.go diff --git a/internal/handlers/objects.go b/backend/internal/handlers/objects.go similarity index 100% rename from internal/handlers/objects.go rename to backend/internal/handlers/objects.go diff --git a/internal/handlers/users.go b/backend/internal/handlers/users.go similarity index 100% rename from internal/handlers/users.go rename to backend/internal/handlers/users.go diff --git a/internal/middleware/auth.go b/backend/internal/middleware/auth.go similarity index 100% rename from internal/middleware/auth.go rename to backend/internal/middleware/auth.go diff --git a/internal/middleware/cors.go b/backend/internal/middleware/cors.go similarity index 100% rename from internal/middleware/cors.go rename to backend/internal/middleware/cors.go diff --git a/internal/models/garage_admin.go b/backend/internal/models/garage_admin.go similarity index 100% rename from internal/models/garage_admin.go rename to backend/internal/models/garage_admin.go diff --git a/internal/models/requests.go b/backend/internal/models/requests.go similarity index 100% rename from internal/models/requests.go rename to backend/internal/models/requests.go diff --git a/internal/models/responses.go b/backend/internal/models/responses.go similarity index 100% rename from internal/models/responses.go rename to backend/internal/models/responses.go diff --git a/internal/routes/routes.go b/backend/internal/routes/routes.go similarity index 88% rename from internal/routes/routes.go rename to backend/internal/routes/routes.go index e47d980..365a2e3 100644 --- a/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -5,9 +5,12 @@ import ( "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" @@ -198,4 +201,31 @@ func SetupRoutes( }) } } + + // 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/internal/services/garage_admin.go b/backend/internal/services/garage_admin.go similarity index 100% rename from internal/services/garage_admin.go rename to backend/internal/services/garage_admin.go diff --git a/backend/internal/services/s3.go b/backend/internal/services/s3.go new file mode 100644 index 0000000..ad9e99c --- /dev/null +++ b/backend/internal/services/s3.go @@ -0,0 +1,490 @@ +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 = 100 + } + + // Create list objects options + opts := minio.ListObjectsOptions{ + Prefix: prefix, + Recursive: false, // Use delimiter to get folders + MaxKeys: maxKeys, + } + + // Note: MinIO SDK v7 doesn't directly support continuation tokens in the same way + // We'll use the ListObjects which returns a channel + + objects := make([]models.ObjectInfo, 0) + prefixes := make(map[string]bool) // Use map to deduplicate prefixes + + // List objects + objectCh := client.ListObjects(ctx, bucketName, opts) + + count := 0 + for object := range objectCh { + 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 object.Key[len(object.Key)-1:] == "/" && object.Size == 0 { + prefixes[object.Key] = true + continue + } + + // 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, + }) + + count++ + if count >= maxKeys { + break + } + } + + // Convert prefixes map to slice + prefixList := make([]string, 0, len(prefixes)) + for p := range prefixes { + prefixList = append(prefixList, p) + } + + return &models.ObjectListResponse{ + Bucket: bucketName, + Objects: objects, + Prefixes: prefixList, + Count: len(objects), + IsTruncated: count >= maxKeys, + NextContinuationToken: "", // MinIO SDK handles this differently + }, 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/main.go b/backend/main.go similarity index 100% rename from main.go rename to backend/main.go diff --git a/backend/pkg/logger/logger.go b/backend/pkg/logger/logger.go new file mode 100644 index 0000000..7d22748 --- /dev/null +++ b/backend/pkg/logger/logger.go @@ -0,0 +1,121 @@ +package logger + +import ( + "io" + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +// Logger is a wrapper around zerolog.Logger +type Logger struct { + zerolog.Logger +} + +var ( + // Global logger instance + globalLogger *Logger +) + +// Config holds logger configuration +type Config struct { + Level string // debug, info, warn, error + Pretty bool // Enable console pretty printing + TimeFormat string // Time format (default: time.RFC3339) +} + +// Init initializes the global logger with the given configuration +func Init(cfg Config) { + var output io.Writer = os.Stdout + + // Set up pretty console output if enabled + if cfg.Pretty { + output = zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: time.RFC3339, + NoColor: false, + } + } + + // Parse log level + level := zerolog.InfoLevel + switch cfg.Level { + case "debug": + level = zerolog.DebugLevel + case "info": + level = zerolog.InfoLevel + case "warn": + level = zerolog.WarnLevel + case "error": + level = zerolog.ErrorLevel + } + + // Create logger + logger := zerolog.New(output). + Level(level). + With(). + Timestamp(). + Caller(). + Logger() + + globalLogger = &Logger{logger} + log.Logger = logger +} + +// Get returns the global logger instance +func Get() *Logger { + if globalLogger == nil { + // Initialize with defaults if not initialized + Init(Config{ + Level: "info", + Pretty: true, + }) + } + return globalLogger +} + +// Debug logs a debug message +func Debug() *zerolog.Event { + return Get().Debug() +} + +// Info logs an info message +func Info() *zerolog.Event { + return Get().Info() +} + +// Warn logs a warning message +func Warn() *zerolog.Event { + return Get().Warn() +} + +// Error logs an error message +func Error() *zerolog.Event { + return Get().Error() +} + +// Fatal logs a fatal message and exits +func Fatal() *zerolog.Event { + return Get().Fatal() +} + +// WithContext creates a new logger with additional context fields +func (l *Logger) WithContext(fields map[string]interface{}) *Logger { + ctx := l.Logger.With() + for k, v := range fields { + ctx = ctx.Interface(k, v) + } + return &Logger{ctx.Logger()} +} + +// WithComponent creates a logger with a component field +func WithComponent(component string) *Logger { + return &Logger{Get().With().Str("component", component).Logger()} +} + +// WithError creates a logger with an error field +func WithError(err error) *zerolog.Event { + return Get().Error().Err(err) +} diff --git a/pkg/utils/cache.go b/backend/pkg/utils/cache.go similarity index 100% rename from pkg/utils/cache.go rename to backend/pkg/utils/cache.go diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..9b805ed --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,92 @@ +# Garage UI Backend Configuration + +# Server configuration +server: + host: "0.0.0.0" + port: 8080 + environment: "development" # development, production + +# Garage S3 Configuration +garage: + endpoint: "http://localhost:3900" # Garage S3 API endpoint + region: "eu-west-1" # S3 region (can be any value for Garage) + + # Garage Admin API configuration + admin_endpoint: "http://localhost:3903" # Garage Admin API endpoint + admin_token: "changeme" # Admin API bearer token + +# Authentication Configuration +auth: + # Auth mode: "none", "basic", or "oidc" + mode: "none" + + # Basic Authentication (only used when mode = "basic") + basic: + username: "admin" + password: "changeme" + + # OIDC Configuration (only used when mode = "oidc") + oidc: + enabled: true + provider_name: "Keycloak" + client_id: "garage-ui" + client_secret: "your-client-secret" + + # OIDC scopes to request + scopes: + - openid + - email + - profile + + # OIDC Provider URLs + issuer_url: "https://keycloak.example.com/realms/master" + auth_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/auth" + token_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/token" + userinfo_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo" + + # Token validation + skip_issuer_check: false + skip_expiry_check: false + + # Attribute mappings + email_attribute: "email" + username_attribute: "preferred_username" + name_attribute: "name" + + # Role-based access (optional) + role_attribute_path: "resource_access.garage-ui.roles" + admin_role: "admin" + + # TLS configuration + tls_skip_verify: false # Only set to true for testing, not recommended for production + + # Session configuration + session_max_age: 86400 # 24 hours in seconds + cookie_name: "garage_session" + cookie_secure: false # Set to true in production with HTTPS + cookie_http_only: true + cookie_same_site: "lax" # lax, strict, none + +# CORS Configuration (for frontend) +cors: + enabled: true + allowed_origins: + - "*" # Vite default + allowed_methods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + allowed_headers: + - Origin + - Content-Type + - Accept + - Authorization + allow_credentials: false + max_age: 3600 + +# Logging +logging: + level: "info" # debug, info, warn, error + format: "json" # json, text diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2f6ade4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +--- +services: + garage: + image: dxflrs/garage:v2.0.0 + container_name: garage + volumes: + - ./garage.toml:/etc/garage.toml + - ./meta:/var/lib/garage/meta + - ./data:/var/lib/garage/data + restart: unless-stopped + ports: + - "3900:3900" + - "3901:3901" + - "3902:3903" + - "3903:3903" + + garage-ui: + image: noooste/garage-ui:latest + container_name: garage-ui + restart: unless-stopped + volumes: + - ./config.yaml:/app/config.yaml + ports: + - "127.0.0.1:8080:8080" + depends_on: + - garage + environment: + # Garage S3 Configuration + GARAGE_UI_GARAGE_ENDPOINT: "http://garage:3900" + GARAGE_UI_GARAGE_ADMIN_ENDPOINT: "http://garage:3903" + + # Server Configuration + GARAGE_UI_SERVER_HOST: "0.0.0.0" + GARAGE_UI_SERVER_PORT: "8080" + GARAGE_UI_SERVER_ENVIRONMENT: "production" + + # Logging + GARAGE_UI_LOGGING_LEVEL: "info" + GARAGE_UI_LOGGING_FORMAT: "json" diff --git a/frontend/src/components/buckets/ObjectBrowserView.tsx b/frontend/src/components/buckets/ObjectBrowserView.tsx index fb2a138..7431446 100644 --- a/frontend/src/components/buckets/ObjectBrowserView.tsx +++ b/frontend/src/components/buckets/ObjectBrowserView.tsx @@ -1,14 +1,14 @@ -import { useState } from 'react'; -import { useDropzone } from 'react-dropzone'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Header } from '@/components/layout/header'; -import { ObjectsTable } from './ObjectsTable'; -import { CreateDirectoryDialog } from './CreateDirectoryDialog'; -import { DeleteObjectDialog } from './DeleteObjectDialog'; -import { ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload } from 'lucide-react'; -import { getBreadcrumbs } from '@/lib/file-utils'; -import type { S3Object } from '@/types'; +import {useState} from 'react'; +import {useDropzone} from 'react-dropzone'; +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {Header} from '@/components/layout/header'; +import {ObjectsTable} from './ObjectsTable'; +import {CreateDirectoryDialog} from './CreateDirectoryDialog'; +import {DeleteObjectDialog} from './DeleteObjectDialog'; +import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload} from 'lucide-react'; +import {getBreadcrumbs} from '@/lib/file-utils'; +import type {S3Object} from '@/types'; interface ObjectBrowserViewProps { bucketName: string; @@ -66,14 +66,17 @@ export function ObjectBrowserView({ const [selectedFileKeys, setSelectedFileKeys] = useState>(new Set()); const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDrop: async (acceptedFiles, fileRejections, event) => { + onDrop: async (acceptedFiles, _fileRejections, event) => { // Get files with their full paths from DataTransferItems API const filesWithPaths: File[] = []; - if (event.dataTransfer?.items) { + // Type cast event to DragEvent to access dataTransfer + const dragEvent = event as DragEvent; + + if (dragEvent.dataTransfer?.items) { // Use DataTransferItemList API to preserve folder structure - const items = Array.from(event.dataTransfer.items); - await Promise.all(items.map(async (item) => { + const items = Array.from(dragEvent.dataTransfer.items); + await Promise.all(items.map(async (item: DataTransferItem) => { if (item.kind === 'file') { const entry = item.webkitGetAsEntry?.(); if (entry) { diff --git a/frontend/src/lib/query-client.ts b/frontend/src/lib/query-client.ts index d0d0936..12e3f76 100644 --- a/frontend/src/lib/query-client.ts +++ b/frontend/src/lib/query-client.ts @@ -1,4 +1,4 @@ -import { QueryClient } from '@tanstack/react-query'; +import {QueryClient} from '@tanstack/react-query'; // Create a query client with default options export const queryClient = new QueryClient({ @@ -9,7 +9,7 @@ export const queryClient = new QueryClient({ retry: 1, // Retry failed requests once refetchOnWindowFocus: false, // Don't refetch when window regains focus refetchOnMount: false, // Don't refetch on component mount if data exists - placeholderData: (previousData) => previousData, // Keep previous data while fetching new data + placeholderData: (previousData: unknown) => previousData, // Keep previous data while fetching new data }, }, }); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f6899cb..3ee1b0c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,10 +1,11 @@ -import { defineConfig } from 'vite' +import {defineConfig} from 'vite' import react from '@vitejs/plugin-react' import path from 'path' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + base: '/', // Base path for production (served from root) resolve: { alias: { '@': path.resolve(__dirname, './src'), @@ -19,4 +20,9 @@ export default defineConfig({ }, }, }, + build: { + outDir: 'dist', + assetsDir: 'assets', + sourcemap: false, + }, }) diff --git a/internal/services/s3.go b/internal/services/s3.go deleted file mode 100644 index 756e7dd..0000000 --- a/internal/services/s3.go +++ /dev/null @@ -1,543 +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/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/aws/aws-sdk-go-v2/service/s3/types" -) - -// S3Service handles all S3 operations with Garage -type S3Service struct { - client *s3.Client - config *config.GarageConfig - adminService *GarageAdminService -} - -// NewS3Service creates a new S3 service instance -func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service { - // Create AWS credentials from Garage config (default/fallback credentials) - creds := credentials.NewStaticCredentialsProvider( - cfg.AccessKey, - cfg.SecretKey, - "", // session token (not used for Garage) - ) - - // Configure S3 client for Garage - s3Config := aws.Config{ - Region: cfg.Region, - Credentials: creds, - } - - // Create S3 client with custom endpoint resolver for Garage - client := s3.NewFromConfig(s3Config, func(o *s3.Options) { - o.BaseEndpoint = aws.String(cfg.Endpoint) - o.UsePathStyle = cfg.ForcePathStyle - }) - - 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) (aws.CredentialsProvider, error) { - cacheKey := fmt.Sprintf("key:%s", bucketName) - cacheData := utils.GlobalCache.Get(cacheKey) - - if cacheData != nil { - return cacheData.(aws.CredentialsProvider), 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 provider - credential := credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "") - - // Cache credentials for 1 hour - utils.GlobalCache.Set(cacheKey, credential, time.Hour) - - return credential, nil -} - -// getS3Client creates an S3 client for a specific bucket with dynamic credentials -func (s *S3Service) getS3Client(ctx context.Context, bucketName string) (*s3.Client, error) { - creds, err := s.getBucketCredentials(ctx, bucketName) - if err != nil { - return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucketName, err) - } - - // AWS config - awsConfig := aws.Config{ - Credentials: creds, - Region: s.config.Region, - } - - // Build S3 client with BaseEndpoint for Garage - client := s3.NewFromConfig(awsConfig, func(o *s3.Options) { - o.BaseEndpoint = aws.String(s.config.Endpoint) - o.UsePathStyle = s.config.ForcePathStyle - o.EndpointResolver = s3.EndpointResolverFunc(func(region string, opts s3.EndpointResolverOptions) (aws.Endpoint, error) { - return aws.Endpoint{ - URL: s.config.Endpoint, - SigningRegion: s.config.Region, - }, nil - }) - }) - - return client, nil -} - -// ListBuckets retrieves all buckets from Garage -func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) { - // Call S3 ListBuckets API - result, err := s.client.ListBuckets(ctx, &s3.ListBucketsInput{}) - if err != nil { - return nil, fmt.Errorf("failed to list buckets: %w", err) - } - - // Convert S3 buckets to our model - buckets := make([]models.BucketInfo, 0, len(result.Buckets)) - for _, bucket := range result.Buckets { - buckets = append(buckets, models.BucketInfo{ - Name: aws.ToString(bucket.Name), - CreationDate: aws.ToTime(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.getS3Client(ctx, bucketName) - if err != nil { - return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) - } - - input := &s3.CreateBucketInput{ - Bucket: aws.String(bucketName), - } - - // Call S3 CreateBucket API - _, err = client.CreateBucket(ctx, input) - 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.getS3Client(ctx, bucketName) - if err != nil { - return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) - } - - // Call S3 DeleteBucket API - _, err = client.DeleteBucket(ctx, &s3.DeleteBucketInput{ - Bucket: aws.String(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 S3 client - client, err := s.getS3Client(ctx, bucketName) - if err != nil { - return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) - } - - // Set default max keys if not specified - if maxKeys <= 0 { - maxKeys = 100 - } - - // Create list objects input - input := &s3.ListObjectsV2Input{ - Bucket: aws.String(bucketName), - Delimiter: aws.String("/"), - MaxKeys: aws.Int32(int32(maxKeys)), - } - - if prefix != "" { - input.Prefix = aws.String(prefix) - } - - if continuationToken != "" { - input.ContinuationToken = aws.String(continuationToken) - } - - // Call S3 ListObjectsV2 API - result, err := client.ListObjectsV2(ctx, input) - if err != nil { - return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err) - } - - // Convert S3 objects to our model - objects := make([]models.ObjectInfo, 0, len(result.Contents)) - for _, obj := range result.Contents { - objects = append(objects, models.ObjectInfo{ - Key: aws.ToString(obj.Key), - Size: aws.ToInt64(obj.Size), - LastModified: aws.ToTime(obj.LastModified), - ETag: aws.ToString(obj.ETag), - StorageClass: string(obj.StorageClass), - }) - } - - // Extract common prefixes (folders/directories) - prefixes := make([]string, 0, len(result.CommonPrefixes)) - for _, p := range result.CommonPrefixes { - prefixes = append(prefixes, aws.ToString(p.Prefix)) - } - - return &models.ObjectListResponse{ - Bucket: bucketName, - Objects: objects, - Prefixes: prefixes, - Count: len(objects), - IsTruncated: aws.ToBool(result.IsTruncated), - NextContinuationToken: aws.ToString(result.NextContinuationToken), - }, 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 S3 client - client, err := s.getS3Client(ctx, bucketName) - if err != nil { - return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) - } - - // Create put object input - input := &s3.PutObjectInput{ - Bucket: aws.String(bucketName), - Key: aws.String(key), - Body: body, - } - - if contentType != "" { - input.ContentType = aws.String(contentType) - } - - // Call S3 PutObject API - result, err := client.PutObject(ctx, input) - if err != nil { - return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err) - } - - // Get object metadata to return size - headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{ - Bucket: aws.String(bucketName), - Key: aws.String(key), - }) - - var size int64 - if err == nil { - size = aws.ToInt64(headResult.ContentLength) - } - - return &models.ObjectUploadResponse{ - Bucket: bucketName, - Key: key, - ETag: aws.ToString(result.ETag), - Size: 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 S3 GetObject API - result, err := s.client.GetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(bucketName), - Key: aws.String(key), - }) - if err != nil { - return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err) - } - - // Create object info - objectInfo := &models.ObjectInfo{ - Key: key, - Size: aws.ToInt64(result.ContentLength), - LastModified: aws.ToTime(result.LastModified), - ETag: aws.ToString(result.ETag), - ContentType: aws.ToString(result.ContentType), - } - - return result.Body, objectInfo, nil -} - -// DeleteObject deletes an object from a bucket -func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error { - // Call S3 DeleteObject API - _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ - Bucket: aws.String(bucketName), - Key: aws.String(key), - }) - 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 S3 client - client, err := s.getS3Client(ctx, bucketName) - if err != nil { - return false, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) - } - - _, err = client.HeadObject(ctx, &s3.HeadObjectInput{ - Bucket: aws.String(bucketName), - Key: aws.String(key), - }) - if err != nil { - return false, nil - } - 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 S3 client - client, err := s.getS3Client(ctx, bucketName) - if err != nil { - return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) - } - - result, err := client.HeadObject(ctx, &s3.HeadObjectInput{ - Bucket: aws.String(bucketName), - Key: aws.String(key), - }) - 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: aws.ToInt64(result.ContentLength), - LastModified: aws.ToTime(result.LastModified), - ETag: aws.ToString(result.ETag), - ContentType: aws.ToString(result.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 S3 client - client, err := s.getS3Client(ctx, bucketName) - if err != nil { - return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) - } - - // Create delete objects for batch deletion - objects := make([]types.ObjectIdentifier, len(keys)) - for i, key := range keys { - objects[i] = types.ObjectIdentifier{ - Key: aws.String(key), - } - } - - // Call S3 DeleteObjects API (batch delete) - _, err = client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ - Bucket: aws.String(bucketName), - Delete: &types.Delete{ - Objects: objects, - Quiet: aws.Bool(false), - }, - }) - if err != nil { - return fmt.Errorf("failed to delete multiple objects from bucket %s: %w", bucketName, 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 S3 client - client, err := s.getS3Client(ctx, bucketName) - if err != nil { - return "", fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) - } - - // Create presign client - presignClient := s3.NewPresignClient(client) - - // Generate presigned GET request - presignResult, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(bucketName), - Key: aws.String(key), - }, func(opts *s3.PresignOptions) { - opts.Expires = expiresIn - }) - if err != nil { - return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err) - } - - return presignResult.URL, 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 S3 client once for all uploads - client, err := s.getS3Client(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 S3 client for bucket %s: %w", bucketName, err), - } - } - return results - } - - // Upload each file - for i, file := range files { - // Create put object input - input := &s3.PutObjectInput{ - Bucket: aws.String(bucketName), - Key: aws.String(file.Key), - Body: file.Body, - } - - if file.ContentType != "" { - input.ContentType = aws.String(file.ContentType) - } - - // Attempt upload - result, err := client.PutObject(ctx, input) - 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 - } - - // Get object metadata to return size - headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{ - Bucket: aws.String(bucketName), - Key: aws.String(file.Key), - }) - - var size int64 - if err == nil { - size = aws.ToInt64(headResult.ContentLength) - } - - results[i] = UploadResult{ - Key: file.Key, - Success: true, - Error: nil, - ETag: aws.ToString(result.ETag), - Size: 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 -}