diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 1b791f2..bbffe3f 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -37,6 +37,15 @@ export interface S3Object {
isFolder?: boolean;
}
+export interface ObjectListResponse {
+ bucket: string;
+ objects: S3Object[];
+ prefixes: string[];
+ count: number;
+ isTruncated: boolean;
+ nextContinuationToken?: string;
+}
+
export interface ObjectMetadata {
key: string;
size: number;
@@ -54,7 +63,16 @@ export interface AccessKey {
createdAt: string;
lastUsed?: string;
status: 'active' | 'inactive';
- permissions: Permission[];
+ permissions: BucketPermission[];
+ expiration?: string;
+}
+
+export interface BucketPermission {
+ bucketId: string;
+ bucketName: string;
+ read: boolean;
+ write: boolean;
+ owner: boolean;
}
export interface Permission {
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index 8d8904f..0a5249d 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -8,38 +8,38 @@ export default {
theme: {
extend: {
colors: {
- border: 'hsl(var(--border))',
- input: 'hsl(var(--input))',
- ring: 'hsl(var(--ring))',
- background: 'hsl(var(--background))',
- foreground: 'hsl(var(--foreground))',
+ border: 'var(--border)',
+ input: 'var(--input)',
+ ring: 'var(--ring)',
+ background: 'var(--background)',
+ foreground: 'var(--foreground)',
primary: {
- DEFAULT: 'hsl(var(--primary))',
- foreground: 'hsl(var(--primary-foreground))',
+ DEFAULT: 'var(--primary)',
+ foreground: 'var(--primary-foreground)',
},
secondary: {
- DEFAULT: 'hsl(var(--secondary))',
- foreground: 'hsl(var(--secondary-foreground))',
+ DEFAULT: 'var(--secondary)',
+ foreground: 'var(--secondary-foreground)',
},
destructive: {
- DEFAULT: 'hsl(var(--destructive))',
- foreground: 'hsl(var(--destructive-foreground))',
+ DEFAULT: 'var(--destructive)',
+ foreground: 'var(--destructive-foreground)',
},
muted: {
- DEFAULT: 'hsl(var(--muted))',
- foreground: 'hsl(var(--muted-foreground))',
+ DEFAULT: 'var(--muted)',
+ foreground: 'var(--muted-foreground)',
},
accent: {
- DEFAULT: 'hsl(var(--accent))',
- foreground: 'hsl(var(--accent-foreground))',
+ DEFAULT: 'var(--accent)',
+ foreground: 'var(--accent-foreground)',
},
popover: {
- DEFAULT: 'hsl(var(--popover))',
- foreground: 'hsl(var(--popover-foreground))',
+ DEFAULT: 'var(--popover)',
+ foreground: 'var(--popover-foreground)',
},
card: {
- DEFAULT: 'hsl(var(--card))',
- foreground: 'hsl(var(--card-foreground))',
+ DEFAULT: 'var(--card)',
+ foreground: 'var(--card-foreground)',
},
},
borderRadius: {
diff --git a/go.mod b/go.mod
index de5a28a..19d763b 100644
--- a/go.mod
+++ b/go.mod
@@ -3,49 +3,83 @@ module Noooste/garage-ui
go 1.25.3
require (
- github.com/aws/aws-sdk-go-v2 v1.40.0
- github.com/aws/aws-sdk-go-v2/credentials v1.19.1
- github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0
+ github.com/Noooste/azuretls-client v1.12.9
+ 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/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.1 // 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.4.14 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
- github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // 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.9.5 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // 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/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-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-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-rc.2 // 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/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/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/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/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
)
diff --git a/go.sum b/go.sum
index cae427f..a06d0f6 100644
--- a/go.sum
+++ b/go.sum
@@ -1,41 +1,115 @@
+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/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/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.19.1 h1:JeW+EwmtTE0yXFK8SmklrFh/cGTTXsQJumgMZNlbxfM=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.1/go.mod h1:BOoXiStwTF+fT2XufhO0Efssbi1CNIO/ZXpZu87N0pw=
+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.92.0 h1:8FshVvnV2sr9kOSAbOnc/vwVmmAwMjOedKH6JW2ddPM=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw=
+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/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/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-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.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/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/gofiber/fiber/v3 v3.0.0-rc.3 h1:h0KXuRHbivSslIpoHD1R/XjUsjcGwt+2vK0avFiYonA=
@@ -44,10 +118,17 @@ 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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+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/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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -58,14 +139,22 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+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/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/shamaton/msgpack/v2 v2.4.0 h1:O5Z08MRmbo0lA9o2xnQ4TXx6teJbPqEurqcCOQ8Oi/4=
@@ -80,10 +169,15 @@ 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=
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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -94,21 +188,46 @@ 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.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
+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.6.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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+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/internal/auth/auth.go
index 273c2e2..f4a62eb 100644
--- a/internal/auth/auth.go
+++ b/internal/auth/auth.go
@@ -8,6 +8,7 @@ import (
"strings"
"Noooste/garage-ui/internal/config"
+
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
diff --git a/internal/config/config.go b/internal/config/config.go
index b0e91b9..bbe2c52 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -31,13 +31,15 @@ type GarageConfig struct {
SecretKey string `mapstructure:"secret_key"`
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"`
+ Mode string `mapstructure:"mode"` // "none", "basic", or "oidc"
+ Basic BasicAuthConfig `mapstructure:"basic"`
+ OIDC OIDCConfig `mapstructure:"oidc"`
}
// BasicAuthConfig contains basic authentication settings
@@ -48,28 +50,28 @@ type BasicAuthConfig struct {
// 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"`
+ 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
@@ -143,6 +145,12 @@ func (c *Config) Validate() error {
if c.Garage.SecretKey == "" {
return fmt.Errorf("garage secret_key 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" {
diff --git a/internal/handlers/buckets.go b/internal/handlers/buckets.go
index 6a31faa..8390a89 100644
--- a/internal/handlers/buckets.go
+++ b/internal/handlers/buckets.go
@@ -3,39 +3,96 @@ 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 {
- s3Service *services.S3Service
+ adminService *services.GarageAdminService
+ s3Service *services.S3Service
}
// NewBucketHandler creates a new bucket handler
-func NewBucketHandler(s3Service *services.S3Service) *BucketHandler {
+func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler {
return &BucketHandler{
- s3Service: s3Service,
+ adminService: adminService,
+ s3Service: s3Service,
}
}
-// ListBuckets returns all buckets
-// GET /api/v1/buckets
+// 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
- buckets, err := h.s3Service.ListBuckets(ctx)
+ // 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()),
)
}
- return c.JSON(models.SuccessResponse(buckets))
+ // 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
-// POST /api/v1/buckets
+//
+// @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()
@@ -55,21 +112,24 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
}
// Check if bucket already exists
- exists, err := h.s3Service.BucketExists(ctx, req.Name)
+ 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 exists {
+ if bucketInfo != nil {
return c.Status(fiber.StatusConflict).JSON(
models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"),
)
}
// Create the bucket
- if err := h.s3Service.CreateBucket(ctx, req.Name); err != nil {
+ 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()),
)
@@ -85,7 +145,18 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
}
// DeleteBucket deletes a bucket
-// DELETE /api/v1/buckets/:name
+//
+// @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()
@@ -97,22 +168,22 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
)
}
- // Check if bucket exists
- exists, err := h.s3Service.BucketExists(ctx, bucketName)
+ // 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 !exists {
+ if bucketInfo == nil {
return c.Status(fiber.StatusNotFound).JSON(
- models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
+ models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
)
}
// Delete the bucket
- if err := h.s3Service.DeleteBucket(ctx, bucketName); err != nil {
+ 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()),
)
@@ -128,7 +199,18 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
}
// GetBucketInfo returns information about a specific bucket
-// GET /api/v1/buckets/:name
+//
+// @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()
@@ -140,36 +222,95 @@ func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
)
}
- // Check if bucket exists
- exists, err := h.s3Service.BucketExists(ctx, bucketName)
+ // 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 !exists {
+ if bucketInfo == nil {
return c.Status(fiber.StatusNotFound).JSON(
- models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
+ models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
)
}
- // List all buckets to find this one and get its info
- buckets, err := h.s3Service.ListBuckets(ctx)
+ 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()),
)
}
- // Find the specific bucket
- for _, bucket := range buckets.Buckets {
- if bucket.Name == bucketName {
- return c.JSON(models.SuccessResponse(bucket))
- }
+ if bucketInfo == nil {
+ return c.Status(fiber.StatusNotFound).JSON(
+ models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
+ )
}
- return c.Status(fiber.StatusNotFound).JSON(
- models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
- )
+ // 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/internal/handlers/cluster.go b/internal/handlers/cluster.go
new file mode 100644
index 0000000..0bbede7
--- /dev/null
+++ b/internal/handlers/cluster.go
@@ -0,0 +1,145 @@
+package handlers
+
+import (
+ "Noooste/garage-ui/internal/models"
+ "Noooste/garage-ui/internal/services"
+
+ "github.com/gofiber/fiber/v3"
+)
+
+// ClusterHandler handles cluster management operations
+type ClusterHandler struct {
+ adminService *services.GarageAdminService
+}
+
+// NewClusterHandler creates a new cluster handler
+func NewClusterHandler(adminService *services.GarageAdminService) *ClusterHandler {
+ return &ClusterHandler{
+ adminService: adminService,
+ }
+}
+
+// GetHealth returns the health status of the cluster
+//
+// @Summary Get cluster health
+// @Description Retrieves the overall health status of the Garage storage cluster
+// @Tags Cluster
+// @Accept json
+// @Produce json
+// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster health"
+// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster health"
+// @Router /api/v1/cluster/health [get]
+func (h *ClusterHandler) GetHealth(c fiber.Ctx) error {
+ ctx := c.Context()
+
+ health, err := h.adminService.GetClusterHealth(ctx)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(
+ models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster health: "+err.Error()),
+ )
+ }
+
+ return c.JSON(models.SuccessResponse(health))
+}
+
+// GetStatus returns the status of the cluster
+//
+// @Summary Get cluster status
+// @Description Retrieves the current status of the Garage storage cluster
+// @Tags Cluster
+// @Accept json
+// @Produce json
+// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster status"
+// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster status"
+// @Router /api/v1/cluster/status [get]
+func (h *ClusterHandler) GetStatus(c fiber.Ctx) error {
+ ctx := c.Context()
+
+ status, err := h.adminService.GetClusterStatus(ctx)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(
+ models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster status: "+err.Error()),
+ )
+ }
+
+ return c.JSON(models.SuccessResponse(status))
+}
+
+// GetStatistics returns global cluster statistics
+// GET /api/v1/cluster/statistics
+func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error {
+ ctx := c.Context()
+
+ stats, err := h.adminService.GetClusterStatistics(ctx)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(
+ models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster statistics: "+err.Error()),
+ )
+ }
+
+ return c.JSON(models.SuccessResponse(stats))
+}
+
+// GetNodeInfo returns information about a specific node
+//
+// @Summary Get node information
+// @Description Retrieves detailed information about a specific node in the Garage storage cluster
+// @Tags Cluster
+// @Accept json
+// @Produce json
+// @Param node_id path string true "ID of the node to retrieve information for"
+// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node information"
+// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required"
+// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node information"
+// @Router /api/v1/cluster/nodes/{node_id} [get]
+func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error {
+ ctx := c.Context()
+ nodeID := c.Params("node_id")
+
+ if nodeID == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(
+ models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
+ )
+ }
+
+ info, err := h.adminService.GetNodeInfo(ctx, nodeID)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(
+ models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node info: "+err.Error()),
+ )
+ }
+
+ return c.JSON(models.SuccessResponse(info))
+}
+
+// GetNodeStatistics returns statistics for a specific node
+//
+// @Summary Get node statistics
+// @Description Retrieves performance statistics and metrics for a specific node in the Garage storage cluster
+// @Tags Cluster
+// @Accept json
+// @Produce json
+// @Param node_id path string true "ID of the node to retrieve statistics for"
+// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node statistics"
+// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required"
+// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node statistics"
+// @Router /api/v1/cluster/nodes/{node_id}/statistics [get]
+func (h *ClusterHandler) GetNodeStatistics(c fiber.Ctx) error {
+ ctx := c.Context()
+ nodeID := c.Params("node_id")
+
+ if nodeID == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(
+ models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
+ )
+ }
+
+ stats, err := h.adminService.GetNodeStatistics(ctx, nodeID)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(
+ models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node statistics: "+err.Error()),
+ )
+ }
+
+ return c.JSON(models.SuccessResponse(stats))
+}
diff --git a/internal/handlers/health.go b/internal/handlers/health.go
index 9e54904..323ec08 100644
--- a/internal/handlers/health.go
+++ b/internal/handlers/health.go
@@ -4,6 +4,7 @@ import (
"time"
"Noooste/garage-ui/internal/models"
+
"github.com/gofiber/fiber/v3"
)
@@ -20,6 +21,14 @@ func NewHealthHandler(version string) *HealthHandler {
}
// 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",
diff --git a/internal/handlers/monitoring.go b/internal/handlers/monitoring.go
new file mode 100644
index 0000000..c8a5ce0
--- /dev/null
+++ b/internal/handlers/monitoring.go
@@ -0,0 +1,152 @@
+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,
+ RequestMetrics: models.RequestMetrics{
+ GetRequests: 0,
+ PutRequests: 0,
+ DeleteRequests: 0,
+ ListRequests: 0,
+ Period: "last-24h",
+ },
+ }
+
+ return c.JSON(models.SuccessResponse(dashboardMetrics))
+}
diff --git a/internal/handlers/objects.go b/internal/handlers/objects.go
index 9ee1e40..25b3f72 100644
--- a/internal/handlers/objects.go
+++ b/internal/handlers/objects.go
@@ -2,10 +2,12 @@ package handlers
import (
"io"
+ "strconv"
"time"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
+
"github.com/gofiber/fiber/v3"
)
@@ -21,8 +23,22 @@ func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler {
}
}
-// ListObjects returns all objects in a bucket
-// GET /api/v1/buckets/:bucket/objects
+// 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()
@@ -34,26 +50,20 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
)
}
- // Get query parameters for filtering
+ // Get query parameters for filtering and pagination
prefix := c.Query("prefix", "")
- maxKeys := c.QueryInt("max_keys", 1000)
+ continuationToken := c.Query("continuation_token", "")
- // Check if bucket exists
- exists, err := h.s3Service.BucketExists(ctx, bucketName)
- if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(
- models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
- )
- }
-
- if !exists {
- return c.Status(fiber.StatusNotFound).JSON(
- models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
+ 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)
+ 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()),
@@ -63,8 +73,21 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
return c.JSON(models.SuccessResponse(objects))
}
-// UploadObject uploads a file to a bucket
-// POST /api/v1/buckets/:bucket/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()
@@ -76,20 +99,6 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
)
}
- // Check if bucket exists
- exists, err := h.s3Service.BucketExists(ctx, bucketName)
- if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(
- models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
- )
- }
-
- if !exists {
- return c.Status(fiber.StatusNotFound).JSON(
- models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
- )
- }
-
// Get file from multipart form
file, err := c.FormFile("file")
if err != nil {
@@ -128,8 +137,20 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
}
-// GetObject downloads an object from a bucket
-// GET /api/v1/buckets/:bucket/objects/:key
+// 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()
@@ -168,7 +189,19 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
}
// DeleteObject deletes an object from a bucket
-// DELETE /api/v1/buckets/:bucket/objects/:key
+//
+// @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()
@@ -214,7 +247,18 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
}
// GetObjectMetadata returns metadata for an object without downloading it
-// HEAD /api/v1/buckets/:bucket/objects/:key
+//
+// @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()
@@ -239,8 +283,21 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
return c.JSON(models.SuccessResponse(metadata))
}
-// GetPresignedURL generates a temporary pre-signed URL for an object
-// POST /api/v1/buckets/:bucket/objects/:key/presign
+// 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()
@@ -255,7 +312,15 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
}
// Get expiration time from query parameter (default: 1 hour)
- expiresIn := c.QueryInt("expires_in", 3600)
+ 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)"),
@@ -284,18 +349,30 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
)
}
- response := map[string]interface{}{
- "url": url,
- "expires_in": expiresIn,
- "bucket": bucketName,
- "key": key,
+ response := models.PresignedURLResponse{
+ URL: url,
+ ExpiresIn: expiresIn,
+ Bucket: bucketName,
+ Key: key,
}
return c.JSON(models.SuccessResponse(response))
}
// DeleteMultipleObjects deletes multiple objects from a bucket
-// DELETE /api/v1/buckets/:bucket/objects
+//
+// @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()
@@ -307,9 +384,10 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
)
}
- // Parse request body to get keys
+ // Parse request body to get keys and optional prefix
var req struct {
- Keys []string `json:"keys"`
+ Keys []string `json:"keys"`
+ Prefix string `json:"prefix,omitempty"`
}
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
@@ -330,17 +408,31 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
)
}
- response := map[string]interface{}{
- "bucket": bucketName,
- "deleted": len(req.Keys),
- "keys": req.Keys,
+ response := models.ObjectDeleteMultipleResponse{
+ Bucket: bucketName,
+ Deleted: len(req.Keys),
+ Keys: req.Keys,
}
return c.JSON(models.SuccessResponse(response))
}
// UploadObjectStream uploads an object from request body stream (for large files)
-// PUT /api/v1/buckets/:bucket/objects/:key
+//
+// @Summary Upload object via stream
+// @Description Uploads an object directly from the request body stream, suitable for large files
+// @Tags Objects
+// @Accept application/octet-stream
+// @Produce json
+// @Param bucket path string true "Name of the bucket to upload the object to"
+// @Param key path string true "Object key (path in bucket)"
+// @Param Content-Type header string false "Content type of the object being uploaded"
+// @Param body body string true "Raw binary data of the object"
+// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully"
+// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
+// @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/{key} [put]
func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error {
ctx := c.Context()
@@ -354,20 +446,6 @@ func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error {
)
}
- // Check if bucket exists
- exists, err := h.s3Service.BucketExists(ctx, bucketName)
- if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(
- models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
- )
- }
-
- if !exists {
- return c.Status(fiber.StatusNotFound).JSON(
- models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
- )
- }
-
// Get content type from header
contentType := c.Get("Content-Type", "application/octet-stream")
@@ -384,3 +462,127 @@ func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
}
+
+// 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()),
+ )
+ }
+
+ // Get all files from the form (they should all be under "files" field)
+ 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/internal/handlers/users.go b/internal/handlers/users.go
index 456fe0c..3319fdf 100644
--- a/internal/handlers/users.go
+++ b/internal/handlers/users.go
@@ -1,46 +1,123 @@
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
-// Note: Garage user management typically requires administrative API access
-// This is a placeholder implementation that you'll need to extend based on
-// your Garage setup and how you manage keys/users
+// UserHandler handles user/key management operations using Garage Admin API
type UserHandler struct {
- // In a real implementation, you might have a Garage admin client here
- // or interact with Garage's administrative API
+ adminService *services.GarageAdminService
}
// NewUserHandler creates a new user handler
-func NewUserHandler() *UserHandler {
- return &UserHandler{}
+func NewUserHandler(adminService *services.GarageAdminService) *UserHandler {
+ return &UserHandler{
+ adminService: adminService,
+ }
}
-// ListUsers returns all users/keys
-// GET /api/v1/users
+// 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 {
- // NOTE: This is a placeholder implementation
- // Garage manages keys/users through its administrative RPC interface
- // You'll need to implement this based on your Garage setup
+ ctx := c.Context()
- // For now, return a not implemented response
- return c.Status(fiber.StatusNotImplemented).JSON(
- models.ErrorResponse(models.ErrCodeInternalError,
- "User management not yet implemented. Requires Garage Admin API integration."),
- )
+ 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),
+ }))
}
-// CreateUser creates a new user/key pair
-// POST /api/v1/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 {
- // NOTE: This is a placeholder implementation
- // To implement this, you need to:
- // 1. Connect to Garage's admin RPC interface
- // 2. Call the appropriate key creation endpoint
- // 3. Return the generated access key and secret key
+ ctx := c.Context()
var req models.CreateUserRequest
if err := c.Bind().JSON(&req); err != nil {
@@ -49,16 +126,57 @@ func (h *UserHandler) CreateUser(c fiber.Ctx) error {
)
}
- return c.Status(fiber.StatusNotImplemented).JSON(
- models.ErrorResponse(models.ErrCodeInternalError,
- "User creation not yet implemented. Requires Garage Admin API integration."),
- )
+ // 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/key
-// DELETE /api/v1/users/:access_key
+// 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 {
- // NOTE: This is a placeholder implementation
+ ctx := c.Context()
accessKey := c.Params("access_key")
if accessKey == "" {
@@ -67,15 +185,33 @@ func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
)
}
- return c.Status(fiber.StatusNotImplemented).JSON(
- models.ErrorResponse(models.ErrCodeInternalError,
- "User deletion not yet implemented. Requires Garage Admin API integration."),
- )
+ // 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 returns information about a specific user/key
-// GET /api/v1/users/:access_key
+// 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 == "" {
@@ -84,15 +220,52 @@ func (h *UserHandler) GetUser(c fiber.Ctx) error {
)
}
- return c.Status(fiber.StatusNotImplemented).JSON(
- models.ErrorResponse(models.ErrCodeInternalError,
- "User info not yet implemented. Requires Garage Admin API integration."),
- )
+ // 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
-// PATCH /api/v1/users/:access_key
+//
+// @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 == "" {
@@ -108,56 +281,61 @@ func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error {
)
}
- return c.Status(fiber.StatusNotImplemented).JSON(
- models.ErrorResponse(models.ErrCodeInternalError,
- "User permission update not yet implemented. Requires Garage Admin API integration."),
- )
-}
+ // Prepare update request
+ updateReq := models.UpdateKeyRequest{}
-/*
-IMPLEMENTATION NOTES FOR USER MANAGEMENT:
-
-Garage uses an administrative RPC interface for managing keys and buckets.
-To implement user management, you need to:
-
-1. Install garage-admin client or use HTTP RPC calls
-2. Connect to Garage's admin port (typically 3903)
-3. Implement the following operations:
-
- - List keys: GET /v1/key
- - Create key: POST /v1/key
- - Get key info: GET /v1/key?id=
- - Delete key: DELETE /v1/key?id=
- - Update key: POST /v1/key?id=
-
-Example using HTTP client:
-
-import (
- "bytes"
- "encoding/json"
- "net/http"
-)
-
-type GarageAdminClient struct {
- baseURL string
- token string
-}
-
-func (g *GarageAdminClient) ListKeys() ([]KeyInfo, error) {
- req, _ := http.NewRequest("GET", g.baseURL+"/v1/key", nil)
- req.Header.Set("Authorization", "Bearer "+g.token)
-
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return nil, err
+ // 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
+ }
}
- defer resp.Body.Close()
- var keys []KeyInfo
- json.NewDecoder(resp.Body).Decode(&keys)
- return keys, nil
+ // 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))
}
-
-For more information, see:
-https://garagehq.deuxfleurs.fr/documentation/reference-manual/admin-api/
-*/
diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go
index 315384d..8411981 100644
--- a/internal/middleware/auth.go
+++ b/internal/middleware/auth.go
@@ -4,6 +4,7 @@ import (
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
+
"github.com/gofiber/fiber/v3"
)
diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go
index 0abc4d1..bac15f2 100644
--- a/internal/middleware/cors.go
+++ b/internal/middleware/cors.go
@@ -4,6 +4,7 @@ import (
"strings"
"Noooste/garage-ui/internal/config"
+
"github.com/gofiber/fiber/v3"
)
diff --git a/internal/models/garage_admin.go b/internal/models/garage_admin.go
new file mode 100644
index 0000000..7ca63fd
--- /dev/null
+++ b/internal/models/garage_admin.go
@@ -0,0 +1,263 @@
+package models
+
+import "time"
+
+// ====================================
+// Access Key Models
+// ====================================
+
+// GarageKeyInfo represents detailed information about a Garage access key
+type GarageKeyInfo struct {
+ AccessKeyID string `json:"accessKeyId"`
+ Name string `json:"name"`
+ Expired bool `json:"expired"`
+ SecretAccessKey *string `json:"secretAccessKey,omitempty"`
+ Permissions KeyPermissions `json:"permissions"`
+ Buckets []KeyBucketInfo `json:"buckets"`
+ Created *time.Time `json:"created,omitempty"`
+ Expiration *time.Time `json:"expiration,omitempty"`
+}
+
+// KeyPermissions represents permissions for an access key
+type KeyPermissions struct {
+ CreateBucket bool `json:"createBucket"`
+}
+
+// KeyBucketInfo represents bucket information associated with a key
+type KeyBucketInfo struct {
+ ID string `json:"id"`
+ GlobalAliases []string `json:"globalAliases"`
+ LocalAliases []string `json:"localAliases"`
+ Permissions BucketKeyPermission `json:"permissions"`
+}
+
+// BucketKeyPermission represents permissions a key has on a specific bucket
+type BucketKeyPermission struct {
+ Read bool `json:"read"`
+ Write bool `json:"write"`
+ Owner bool `json:"owner"`
+}
+
+// CreateKeyRequest represents the request to create a new access key
+type CreateKeyRequest struct {
+ Name *string `json:"name,omitempty"`
+ Expiration *time.Time `json:"expiration,omitempty"`
+ NeverExpires bool `json:"neverExpires,omitempty"`
+ Allow *KeyPermissions `json:"allow,omitempty"`
+ Deny *KeyPermissions `json:"deny,omitempty"`
+}
+
+// UpdateKeyRequest represents the request to update an access key
+type UpdateKeyRequest struct {
+ Name *string `json:"name,omitempty"`
+ Expiration *time.Time `json:"expiration,omitempty"`
+ NeverExpires bool `json:"neverExpires,omitempty"`
+ Allow *KeyPermissions `json:"allow,omitempty"`
+ Deny *KeyPermissions `json:"deny,omitempty"`
+}
+
+// ImportKeyRequest represents the request to import an existing key
+type ImportKeyRequest struct {
+ AccessKeyID string `json:"accessKeyId"`
+ SecretAccessKey string `json:"secretAccessKey"`
+ Name *string `json:"name,omitempty"`
+}
+
+// ListKeysResponseItem represents a single key in the list response
+type ListKeysResponseItem struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Expired bool `json:"expired"`
+ Created *time.Time `json:"created,omitempty"`
+ Expiration *time.Time `json:"expiration,omitempty"`
+}
+
+// ====================================
+// Bucket Models (Admin API)
+// ====================================
+
+// GarageBucketInfo represents detailed information about a bucket from Admin API
+type GarageBucketInfo struct {
+ ID string `json:"id"`
+ Created time.Time `json:"created"`
+ GlobalAliases []string `json:"globalAliases"`
+ WebsiteAccess bool `json:"websiteAccess"`
+ WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
+ Keys []BucketKeyInfo `json:"keys"`
+ Objects int64 `json:"objects"`
+ Bytes int64 `json:"bytes"`
+ UnfinishedUploads int64 `json:"unfinishedUploads"`
+ UnfinishedMultipartUploads int64 `json:"unfinishedMultipartUploads"`
+ UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"`
+ UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"`
+ Quotas *BucketQuotas `json:"quotas,omitempty"`
+}
+
+// BucketWebsiteConfig represents website configuration for a bucket
+type BucketWebsiteConfig struct {
+ IndexDocument string `json:"indexDocument"`
+ ErrorDocument *string `json:"errorDocument,omitempty"`
+}
+
+// BucketQuotas represents quota settings for a bucket
+type BucketQuotas struct {
+ MaxSize *int64 `json:"maxSize,omitempty"`
+ MaxObjects *int64 `json:"maxObjects,omitempty"`
+}
+
+// BucketKeyInfo represents key information associated with a bucket
+type BucketKeyInfo struct {
+ AccessKeyID string `json:"accessKeyId"`
+ Name string `json:"name"`
+ Permissions BucketKeyPermission `json:"permissions"`
+ BucketLocalAliases []string `json:"bucketLocalAliases"`
+}
+
+// CreateBucketAdminRequest represents the request to create a bucket via Admin API
+type CreateBucketAdminRequest struct {
+ GlobalAlias *string `json:"globalAlias,omitempty"`
+ LocalAlias *CreateBucketLocalAlias `json:"localAlias,omitempty"`
+}
+
+// CreateBucketLocalAlias represents local alias configuration when creating a bucket
+type CreateBucketLocalAlias struct {
+ AccessKeyID string `json:"accessKeyId"`
+ Alias string `json:"alias"`
+ Allow *BucketKeyPermission `json:"allow,omitempty"`
+}
+
+// UpdateBucketRequest represents the request to update bucket settings
+type UpdateBucketRequest struct {
+ WebsiteAccess *UpdateBucketWebsiteAccess `json:"websiteAccess,omitempty"`
+ Quotas *BucketQuotas `json:"quotas,omitempty"`
+}
+
+// UpdateBucketWebsiteAccess represents website access settings update
+type UpdateBucketWebsiteAccess struct {
+ Enabled bool `json:"enabled"`
+ IndexDocument *string `json:"indexDocument,omitempty"`
+ ErrorDocument *string `json:"errorDocument,omitempty"`
+}
+
+// ListBucketsResponseItem represents a single bucket in the list response
+type ListBucketsResponseItem struct {
+ ID string `json:"id"`
+ Created time.Time `json:"created"`
+ GlobalAliases []string `json:"globalAliases"`
+ LocalAliases []BucketLocalAlias `json:"localAliases"`
+}
+
+// BucketLocalAlias represents a local alias for a bucket
+type BucketLocalAlias struct {
+ AccessKeyID string `json:"accessKeyId"`
+ Alias string `json:"alias"`
+}
+
+// ====================================
+// Bucket Alias Models
+// ====================================
+
+// AddBucketAliasRequest represents the request to add a bucket alias
+type AddBucketAliasRequest struct {
+ BucketID string `json:"bucketId"`
+ GlobalAlias *string `json:"globalAlias,omitempty"`
+ LocalAlias *string `json:"localAlias,omitempty"`
+ AccessKeyID *string `json:"accessKeyId,omitempty"`
+}
+
+// RemoveBucketAliasRequest represents the request to remove a bucket alias
+type RemoveBucketAliasRequest struct {
+ BucketID string `json:"bucketId"`
+ GlobalAlias *string `json:"globalAlias,omitempty"`
+ LocalAlias *string `json:"localAlias,omitempty"`
+ AccessKeyID *string `json:"accessKeyId,omitempty"`
+}
+
+// ====================================
+// Permission Models
+// ====================================
+
+// BucketKeyPermRequest represents a request to change bucket-key permissions
+type BucketKeyPermRequest struct {
+ BucketID string `json:"bucketId"`
+ AccessKeyID string `json:"accessKeyId"`
+ Permissions BucketKeyPermission `json:"permissions"`
+}
+
+// ====================================
+// Cluster Models
+// ====================================
+
+// ClusterHealth represents the health status of the cluster
+type ClusterHealth struct {
+ Status string `json:"status"`
+ KnownNodes int `json:"knownNodes"`
+ ConnectedNodes int `json:"connectedNodes"`
+ StorageNodes int `json:"storageNodes"`
+ StorageNodesUp int `json:"storageNodesUp"`
+ Partitions int `json:"partitions"`
+ PartitionsQuorum int `json:"partitionsQuorum"`
+ PartitionsAllOk int `json:"partitionsAllOk"`
+}
+
+// ClusterStatus represents the current status of the cluster
+type ClusterStatus struct {
+ LayoutVersion int `json:"layoutVersion"`
+ Nodes []NodeInfo `json:"nodes"`
+}
+
+// ClusterStatistics represents global cluster statistics
+type ClusterStatistics struct {
+ Freeform string `json:"freeform"`
+}
+
+// ====================================
+// Node Models
+// ====================================
+
+// NodeInfo represents information about a cluster node
+type NodeInfo struct {
+ ID string `json:"id"`
+ IsUp bool `json:"isUp"`
+ LastSeenSecsAgo *int64 `json:"lastSeenSecsAgo,omitempty"`
+ Hostname *string `json:"hostname,omitempty"`
+ Addr *string `json:"addr,omitempty"`
+ GarageVersion *string `json:"garageVersion,omitempty"`
+ Role *NodeRole `json:"role,omitempty"`
+ Draining bool `json:"draining"`
+ DataPartition *FreeSpaceInfo `json:"dataPartition,omitempty"`
+ MetadataPartition *FreeSpaceInfo `json:"metadataPartition,omitempty"`
+}
+
+// NodeRole represents the role assigned to a node
+type NodeRole struct {
+ Zone string `json:"zone"`
+ Capacity *int64 `json:"capacity,omitempty"`
+ Tags []string `json:"tags"`
+}
+
+// FreeSpaceInfo represents disk space information
+type FreeSpaceInfo struct {
+ Available int64 `json:"available"`
+ Total int64 `json:"total"`
+}
+
+// NodeInfoResponse represents the response for GetNodeInfo
+type NodeInfoResponse struct {
+ NodeID string `json:"nodeId"`
+ GarageVersion string `json:"garageVersion"`
+ RustVersion string `json:"rustVersion"`
+ DBEngine string `json:"dbEngine"`
+ GarageFeatures []string `json:"garageFeatures,omitempty"`
+}
+
+// NodeStatisticsResponse represents the response for GetNodeStatistics
+type NodeStatisticsResponse struct {
+ Freeform string `json:"freeform"`
+}
+
+// MultiNodeResponse represents responses from multiple nodes
+type MultiNodeResponse struct {
+ Success map[string]interface{} `json:"success"`
+ Error map[string]string `json:"error"`
+}
diff --git a/internal/models/requests.go b/internal/models/requests.go
index 0857375..34f05a0 100644
--- a/internal/models/requests.go
+++ b/internal/models/requests.go
@@ -6,6 +6,12 @@ type CreateBucketRequest struct {
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"`
@@ -13,10 +19,10 @@ type DeleteBucketRequest struct {
// 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"`
+ 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
@@ -51,6 +57,6 @@ type DeleteUserRequest struct {
// UpdateUserRequest represents a request to update user permissions
type UpdateUserRequest struct {
- AccessKey string `json:"access_key" validate:"required"`
- Permissions []string `json:"permissions,omitempty"`
+ Status *string `json:"status,omitempty"` // "active" or "inactive"
+ Expiration *string `json:"expiration,omitempty"` // ISO 8601 date string
}
diff --git a/internal/models/responses.go b/internal/models/responses.go
index 7e9475a..3a15262 100644
--- a/internal/models/responses.go
+++ b/internal/models/responses.go
@@ -2,6 +2,32 @@ 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"`
+ RequestMetrics RequestMetrics `json:"requestMetrics"`
+}
+
+// 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"`
+}
+
+// RequestMetrics represents API request statistics
+type RequestMetrics struct {
+ GetRequests int64 `json:"getRequests"`
+ PutRequests int64 `json:"putRequests"`
+ DeleteRequests int64 `json:"deleteRequests"`
+ ListRequests int64 `json:"listRequests"`
+ Period string `json:"period"`
+}
+
// APIResponse is the standard response structure for all API endpoints
type APIResponse struct {
Success bool `json:"success"`
@@ -26,6 +52,8 @@ type HealthResponse struct {
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"`
}
@@ -47,11 +75,12 @@ type ObjectInfo struct {
// ObjectListResponse represents a list of objects in a bucket
type ObjectListResponse struct {
- Bucket string `json:"bucket"`
- Objects []ObjectInfo `json:"objects"`
- Count int `json:"count"`
- IsTruncated bool `json:"is_truncated"`
- NextMarker string `json:"next_marker,omitempty"`
+ 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
@@ -63,6 +92,31 @@ type ObjectUploadResponse struct {
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"`
@@ -72,10 +126,44 @@ type ObjectDeleteResponse struct {
// UserInfo represents information about a Garage user (key pair)
type UserInfo struct {
- AccessKey string `json:"access_key"`
- Name string `json:"name,omitempty"`
- Permissions []string `json:"permissions,omitempty"`
- CreatedAt time.Time `json:"created_at,omitempty"`
+ 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
@@ -109,18 +197,18 @@ func ErrorResponse(code, message string) APIResponse {
// 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"
+ 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/internal/routes/routes.go b/internal/routes/routes.go
index 12103c9..e47d980 100644
--- a/internal/routes/routes.go
+++ b/internal/routes/routes.go
@@ -5,7 +5,13 @@ import (
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/middleware"
+
"github.com/gofiber/fiber/v3"
+
+ // Swagger imports
+ _ "Noooste/garage-ui/docs"
+
+ "github.com/Noooste/swagger"
)
// SetupRoutes configures all API routes
@@ -17,6 +23,8 @@ func SetupRoutes(
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))
@@ -25,6 +33,9 @@ func SetupRoutes(
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")
@@ -34,23 +45,25 @@ func SetupRoutes(
// 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.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.Delete("/", objectHandler.DeleteMultipleObjects) // Delete multiple objects
- objects.Get("/:key", objectHandler.GetObject) // Download object
- objects.Put("/:key", objectHandler.UploadObjectStream) // Upload object (stream)
- 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
+ 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.Put("/:key", objectHandler.UploadObjectStream) // Upload object (stream)
+ 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
@@ -63,6 +76,24 @@ func SetupRoutes(
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")
diff --git a/internal/services/garage_admin.go b/internal/services/garage_admin.go
new file mode 100644
index 0000000..1e8f44b
--- /dev/null
+++ b/internal/services/garage_admin.go
@@ -0,0 +1,460 @@
+package services
+
+import (
+ "Noooste/garage-ui/internal/config"
+ "Noooste/garage-ui/internal/models"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/Noooste/azuretls-client"
+)
+
+// GarageAdminService handles interactions with the Garage Admin API
+type GarageAdminService struct {
+ baseURL string
+ token string
+ httpClient *azuretls.Session
+}
+
+// NewGarageAdminService creates a new Garage Admin API service
+func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
+ session := azuretls.NewSession()
+ session.Log()
+
+ return &GarageAdminService{
+ baseURL: cfg.AdminEndpoint,
+ token: cfg.AdminToken,
+ httpClient: session,
+ }
+}
+
+// doRequest performs an HTTP request to the Admin API
+func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
+ return s.httpClient.Do(&azuretls.Request{
+ Method: method,
+ Url: s.baseURL + path,
+ Body: body,
+ IgnoreBody: true, // decodeResponse will handle body reading
+ OrderedHeaders: azuretls.OrderedHeaders{
+ {"Authorization", fmt.Sprintf("Bearer %s", s.token)},
+ },
+ }, ctx)
+}
+
+// decodeResponse decodes a JSON response into the target structure
+func decodeResponse(resp *azuretls.Response, target interface{}) error {
+ defer resp.RawBody.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ bodyBytes, _ := io.ReadAll(resp.RawBody)
+ return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
+ }
+
+ if target != nil {
+ if err := json.NewDecoder(resp.RawBody).Decode(target); err != nil {
+ return fmt.Errorf("failed to decode response: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// ====================================
+// Access Key Operations
+// ====================================
+
+// ListKeys returns all access keys in the cluster
+func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
+ resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result []models.ListKeysResponseItem
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return result, nil
+}
+
+// CreateKey creates a new API access key
+func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
+ resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateKey", req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.GarageKeyInfo
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// GetKeyInfo returns information about a specific access key
+func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
+ path := fmt.Sprintf("/v2/GetKeyInfo?id=%s", keyID)
+ if showSecret {
+ path += "&showSecretKey=true"
+ }
+
+ resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.GarageKeyInfo
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// UpdateKey updates information about an access key
+func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
+ path := fmt.Sprintf("/v2/UpdateKey?id=%s", keyID)
+
+ resp, err := s.doRequest(ctx, http.MethodPost, path, req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.GarageKeyInfo
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// DeleteKey deletes an access key from the cluster
+func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error {
+ path := fmt.Sprintf("/v2/DeleteKey?id=%s", keyID)
+
+ resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
+ if err != nil {
+ return fmt.Errorf("request failed: %w", err)
+ }
+
+ if err := decodeResponse(resp, nil); err != nil {
+ return fmt.Errorf("failed to process response: %w", err)
+ }
+
+ return nil
+}
+
+// ImportKey imports an existing API access key
+func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
+ resp, err := s.doRequest(ctx, http.MethodPost, "/v2/ImportKey", req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.GarageKeyInfo
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// ====================================
+// Bucket Operations (Admin API)
+// ====================================
+
+// ListBuckets returns all buckets in the cluster
+func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
+ resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListBuckets", nil)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result []models.ListBucketsResponseItem
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return result, nil
+}
+
+// GetBucketInfo returns detailed information about a bucket by ID
+func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
+ path := fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID)
+
+ resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.GarageBucketInfo
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// GetBucketInfoByAlias returns detailed information about a bucket by its global alias
+func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
+ path := fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias)
+
+ resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.GarageBucketInfo
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// CreateBucket creates a new bucket via the Admin API
+func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
+ resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateBucket", req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.GarageBucketInfo
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// UpdateBucket updates bucket settings
+func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
+ path := fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID)
+
+ resp, err := s.doRequest(ctx, http.MethodPost, path, req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.GarageBucketInfo
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// DeleteBucket deletes a bucket
+func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error {
+ path := fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID)
+
+ resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
+ if err != nil {
+ return fmt.Errorf("request failed: %w", err)
+ }
+
+ if err := decodeResponse(resp, nil); err != nil {
+ return fmt.Errorf("failed to process response: %w", err)
+ }
+
+ return nil
+}
+
+// ====================================
+// Bucket Alias Operations
+// ====================================
+
+// AddBucketAlias adds an alias to a bucket
+func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
+ resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.GarageBucketInfo
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// RemoveBucketAlias removes an alias from a bucket
+func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
+ resp, err := s.doRequest(ctx, http.MethodPost, "/v2/RemoveBucketAlias", req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.GarageBucketInfo
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// ====================================
+// Permission Operations
+// ====================================
+
+// AllowBucketKey grants permissions for a key on a bucket
+func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
+ resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AllowBucketKey", req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.GarageBucketInfo
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// DenyBucketKey revokes permissions for a key on a bucket
+func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
+ resp, err := s.doRequest(ctx, http.MethodPost, "/v2/DenyBucketKey", req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.GarageBucketInfo
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// ====================================
+// Cluster Operations
+// ====================================
+
+// GetClusterHealth returns the health status of the cluster
+func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
+ resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.ClusterHealth
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// GetClusterStatus returns the current status of the cluster
+func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
+ resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatus", nil)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.ClusterStatus
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// GetClusterStatistics returns global cluster statistics
+func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
+ resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatistics", nil)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.ClusterStatistics
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// ====================================
+// Node Operations
+// ====================================
+
+// GetNodeInfo returns information about a specific node
+func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
+ path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID)
+
+ resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.MultiNodeResponse
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// GetNodeStatistics returns statistics for a specific node
+func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
+ path := fmt.Sprintf("/v2/GetNodeStatistics?node=%s", nodeID)
+
+ resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ var result models.MultiNodeResponse
+ if err := decodeResponse(resp, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
+// ====================================
+// Monitoring Operations
+// ====================================
+
+// HealthCheck checks if the Admin API is reachable
+func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
+ resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
+ if err != nil {
+ return fmt.Errorf("health check failed: %w", err)
+ }
+
+ if err := decodeResponse(resp, nil); err != nil {
+ return fmt.Errorf("health check returned error: %w", err)
+ }
+
+ return nil
+}
+
+// GetMetrics returns Prometheus metrics from the Admin API
+func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) {
+ resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil)
+ if err != nil {
+ return "", fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.RawBody.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ bodyBytes, _ := io.ReadAll(resp.RawBody)
+ return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
+ }
+
+ bodyBytes, err := io.ReadAll(resp.RawBody)
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %w", err)
+ }
+
+ return string(bodyBytes), nil
+}
diff --git a/internal/services/s3.go b/internal/services/s3.go
index 4b7db31..756e7dd 100644
--- a/internal/services/s3.go
+++ b/internal/services/s3.go
@@ -8,6 +8,8 @@ import (
"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"
@@ -16,13 +18,14 @@ import (
// S3Service handles all S3 operations with Garage
type S3Service struct {
- client *s3.Client
- config *config.GarageConfig
+ client *s3.Client
+ config *config.GarageConfig
+ adminService *GarageAdminService
}
// NewS3Service creates a new S3 service instance
-func NewS3Service(cfg *config.GarageConfig) *S3Service {
- // Create AWS credentials from Garage config
+func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
+ // Create AWS credentials from Garage config (default/fallback credentials)
creds := credentials.NewStaticCredentialsProvider(
cfg.AccessKey,
cfg.SecretKey,
@@ -42,11 +45,89 @@ func NewS3Service(cfg *config.GarageConfig) *S3Service {
})
return &S3Service{
- client: client,
- config: cfg,
+ 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
@@ -72,13 +153,17 @@ func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse
// CreateBucket creates a new bucket in Garage
func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
- // Create bucket input
+ 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 := s.client.CreateBucket(ctx, input)
+ _, err = client.CreateBucket(ctx, input)
if err != nil {
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
}
@@ -88,8 +173,13 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
// 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 := s.client.DeleteBucket(ctx, &s3.DeleteBucketInput{
+ _, err = client.DeleteBucket(ctx, &s3.DeleteBucketInput{
Bucket: aws.String(bucketName),
})
if err != nil {
@@ -99,37 +189,36 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
return nil
}
-// BucketExists checks if a bucket exists
-func (s *S3Service) BucketExists(ctx context.Context, bucketName string) (bool, error) {
- _, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{
- Bucket: aws.String(bucketName),
- })
+// 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 {
- // Check if it's a "not found" error
- return false, nil
+ return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err)
}
- return true, nil
-}
-// ListObjects lists objects in a bucket with optional prefix filter
-func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int) (*models.ObjectListResponse, error) {
// Set default max keys if not specified
if maxKeys <= 0 {
- maxKeys = 1000
+ maxKeys = 100
}
// Create list objects input
input := &s3.ListObjectsV2Input{
- Bucket: aws.String(bucketName),
- MaxKeys: aws.Int32(int32(maxKeys)),
+ 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 := s.client.ListObjectsV2(ctx, input)
+ result, err := client.ListObjectsV2(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
}
@@ -146,17 +235,30 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
})
}
+ // 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,
- Count: len(objects),
- IsTruncated: aws.ToBool(result.IsTruncated),
- NextMarker: aws.ToString(result.NextContinuationToken),
+ 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),
@@ -169,13 +271,13 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
}
// Call S3 PutObject API
- result, err := s.client.PutObject(ctx, input)
+ 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 := s.client.HeadObject(ctx, &s3.HeadObjectInput{
+ headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
@@ -233,7 +335,13 @@ func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) er
// ObjectExists checks if an object exists in a bucket
func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
- _, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
+ // 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),
})
@@ -245,7 +353,13 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
// GetObjectMetadata retrieves metadata for an object without downloading it
func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
- result, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
+ // 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),
})
@@ -268,6 +382,12 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
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 {
@@ -277,7 +397,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
}
// Call S3 DeleteObjects API (batch delete)
- _, err := s.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
+ _, err = client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: aws.String(bucketName),
Delete: &types.Delete{
Objects: objects,
@@ -294,8 +414,14 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
// 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(s.client)
+ presignClient := s3.NewPresignClient(client)
// Generate presigned GET request
presignResult, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
@@ -310,3 +436,108 @@ func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string,
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
+}
diff --git a/cmd/api/main.go b/main.go
similarity index 62%
rename from cmd/api/main.go
rename to main.go
index 0dac49a..cb2352f 100644
--- a/cmd/api/main.go
+++ b/main.go
@@ -13,11 +13,51 @@ import (
"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() {
@@ -35,8 +75,11 @@ func main() {
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)
+ s3Service := services.NewS3Service(&cfg.Garage, adminService)
log.Printf("Initializing authentication service (mode: %s)...", cfg.Auth.Mode)
authService, err := auth.NewAuthService(&cfg.Auth)
@@ -46,16 +89,18 @@ func main() {
// Initialize handlers
healthHandler := handlers.NewHealthHandler(version)
- bucketHandler := handlers.NewBucketHandler(s3Service)
+ bucketHandler := handlers.NewBucketHandler(adminService, s3Service)
objectHandler := handlers.NewObjectHandler(s3Service)
- userHandler := handlers.NewUserHandler()
+ 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,
+ AppName: "Garage UI Backend v" + version,
+ //DisableStartupMessage: false,
+ //EnablePrintRoutes: cfg.IsDevelopment(),
+ ErrorHandler: customErrorHandler,
})
// Apply global middleware
@@ -74,6 +119,8 @@ func main() {
bucketHandler,
objectHandler,
userHandler,
+ clusterHandler,
+ monitoringHandler,
)
// Start server in a goroutine
diff --git a/pkg/utils/cache.go b/pkg/utils/cache.go
new file mode 100644
index 0000000..5413ea0
--- /dev/null
+++ b/pkg/utils/cache.go
@@ -0,0 +1,95 @@
+package utils
+
+import (
+ "sync"
+ "time"
+)
+
+// CacheItem represents a cached item with expiration
+type CacheItem struct {
+ Value interface{}
+ Expiration time.Time
+}
+
+// Cache represents a simple in-memory cache with expiration
+type Cache struct {
+ mu sync.RWMutex
+ items map[string]CacheItem
+}
+
+// NewCache creates a new cache instance
+func NewCache() *Cache {
+ c := &Cache{
+ items: make(map[string]CacheItem),
+ }
+
+ // Start cleanup goroutine
+ go c.cleanupExpired()
+
+ return c
+}
+
+// Get retrieves a value from the cache
+func (c *Cache) Get(key string) interface{} {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+
+ item, exists := c.items[key]
+ if !exists {
+ return nil
+ }
+
+ // Check if item has expired
+ if time.Now().After(item.Expiration) {
+ return nil
+ }
+
+ return item.Value
+}
+
+// Set stores a value in the cache with an expiration duration
+func (c *Cache) Set(key string, value interface{}, duration time.Duration) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.items[key] = CacheItem{
+ Value: value,
+ Expiration: time.Now().Add(duration),
+ }
+}
+
+// Delete removes a value from the cache
+func (c *Cache) Delete(key string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ delete(c.items, key)
+}
+
+// Clear removes all items from the cache
+func (c *Cache) Clear() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.items = make(map[string]CacheItem)
+}
+
+// cleanupExpired periodically removes expired items
+func (c *Cache) cleanupExpired() {
+ ticker := time.NewTicker(5 * time.Minute)
+ defer ticker.Stop()
+
+ for range ticker.C {
+ c.mu.Lock()
+ now := time.Now()
+ for key, item := range c.items {
+ if now.After(item.Expiration) {
+ delete(c.items, key)
+ }
+ }
+ c.mu.Unlock()
+ }
+}
+
+// Global cache instance
+var GlobalCache = NewCache()