mirror of
				https://github.com/khairul169/garage-webui.git
				synced 2025-11-04 08:51:06 +07:00 
			
		
		
		
	Compare commits
	
		
			40 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ee420fbf29 | |||
| 615f72249e | |||
| 
						 | 
					d1ad6a008d | ||
| 
						 | 
					fbcd83709b | ||
| a6640157c1 | |||
| 72411c7f97 | |||
| 90a1018235 | |||
| aeedaaa72e | |||
| 
						 | 
					c7d3d224ba | ||
| 
						 | 
					5f0f200c03 | ||
| 
						 | 
					ff39a6cd82 | ||
| 
						 | 
					6c6b9b076b | ||
| b2bc905e3c | |||
| 59e68455b6 | |||
| f30d4289f1 | |||
| e6e21d3c01 | |||
| 2aaaf87dfd | |||
| 04a10eadfd | |||
| 8c3458c27f | |||
| f8e65ccc0e | |||
| b53859ae23 | |||
| 8728108d18 | |||
| 0d844c7ac6 | |||
| ccfa2cde25 | |||
| b8b87d8289 | |||
| 1b1b815443 | |||
| 37027396ca | |||
| c1619276c0 | |||
| 611258d0db | |||
| 91c396dd68 | |||
| 5a90dd8377 | |||
| 145bf3f1a9 | |||
| 7532c6330c | |||
| 4861e1bbb1 | |||
| 93b301186a | |||
| e84dd657fb | |||
| 4fb06faaf4 | |||
| 93a1dce5f7 | |||
| 934e0c409c | |||
| 3a147f4133 | 
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -26,3 +26,7 @@ dist-ssr
 | 
			
		||||
.env*
 | 
			
		||||
!.env.example
 | 
			
		||||
docker-compose.*.yml
 | 
			
		||||
 | 
			
		||||
data/
 | 
			
		||||
meta/
 | 
			
		||||
garage.toml
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										14
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								Dockerfile
									
									
									
									
									
								
							@ -1,9 +1,7 @@
 | 
			
		||||
FROM node:20-slim AS frontend
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
 | 
			
		||||
ENV PNPM_HOME="/pnpm"
 | 
			
		||||
ENV PATH="$PNPM_HOME:$PATH"
 | 
			
		||||
RUN corepack enable
 | 
			
		||||
RUN npm install -g corepack@latest && corepack use pnpm@latest
 | 
			
		||||
 | 
			
		||||
COPY package.json pnpm-lock.yaml ./
 | 
			
		||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
 | 
			
		||||
@ -11,7 +9,7 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
 | 
			
		||||
COPY . .
 | 
			
		||||
RUN pnpm run build
 | 
			
		||||
 | 
			
		||||
FROM golang:1.22.5 AS backend
 | 
			
		||||
FROM golang:1.23 AS backend
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
 | 
			
		||||
COPY backend/go.mod backend/go.sum ./
 | 
			
		||||
@ -23,6 +21,12 @@ RUN make
 | 
			
		||||
 | 
			
		||||
FROM scratch
 | 
			
		||||
 | 
			
		||||
COPY --from=alpine /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
 | 
			
		||||
COPY --from=ghcr.io/tarampampam/curl:8.6.0 /bin/curl /bin/curl
 | 
			
		||||
COPY --from=backend /app/main /bin/main
 | 
			
		||||
 | 
			
		||||
CMD [ "/bin/main" ]
 | 
			
		||||
HEALTHCHECK --interval=5m --timeout=2s --retries=3 --start-period=15s CMD [ \
 | 
			
		||||
    "curl", "--fail", "http://127.0.0.1:3909" \
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
ENTRYPOINT [ "main" ]
 | 
			
		||||
							
								
								
									
										95
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										95
									
								
								README.md
									
									
									
									
									
								
							@ -6,9 +6,17 @@ A simple admin web UI for [Garage](https://garagehq.deuxfleurs.fr/), a self-host
 | 
			
		||||
 | 
			
		||||
[ [Screenshots](misc/SCREENSHOTS.md) | [Install Garage](https://garagehq.deuxfleurs.fr/documentation/quick-start/) | [Garage Git](https://git.deuxfleurs.fr/Deuxfleurs/garage) ]
 | 
			
		||||
 | 
			
		||||
## Features
 | 
			
		||||
 | 
			
		||||
- Garage health status
 | 
			
		||||
- Cluster & layout management
 | 
			
		||||
- Create, update, or view bucket information
 | 
			
		||||
- Integrated objects/bucket browser
 | 
			
		||||
- Create & assign access keys
 | 
			
		||||
 | 
			
		||||
## Installation
 | 
			
		||||
 | 
			
		||||
The Garage Web UI is available as a Docker image. You can install it using the command line or with Docker Compose.
 | 
			
		||||
The Garage Web UI is available as a single executable binary and docker image. You can install it using the command line or with Docker Compose.
 | 
			
		||||
 | 
			
		||||
### Docker CLI
 | 
			
		||||
 | 
			
		||||
@ -23,14 +31,18 @@ If you install Garage using Docker, you can install this web UI alongside Garage
 | 
			
		||||
```yml
 | 
			
		||||
services:
 | 
			
		||||
  garage:
 | 
			
		||||
    image: dxflrs/garage:v1.0.0
 | 
			
		||||
    image: dxflrs/garage:v2.0.0
 | 
			
		||||
    container_name: garage
 | 
			
		||||
    volumes:
 | 
			
		||||
      - ./garage.toml:/etc/garage.toml
 | 
			
		||||
      - ./meta:/var/lib/garage/meta
 | 
			
		||||
      - ./data:/var/lib/garage/data
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    network_mode: host
 | 
			
		||||
    ports:
 | 
			
		||||
      - 3900:3900
 | 
			
		||||
      - 3901:3901
 | 
			
		||||
      - 3902:3902
 | 
			
		||||
      - 3903:3903
 | 
			
		||||
 | 
			
		||||
  webui:
 | 
			
		||||
    image: khairul169/garage-webui:latest
 | 
			
		||||
@ -40,6 +52,53 @@ services:
 | 
			
		||||
      - ./garage.toml:/etc/garage.toml:ro
 | 
			
		||||
    ports:
 | 
			
		||||
      - 3909:3909
 | 
			
		||||
    environment:
 | 
			
		||||
      API_BASE_URL: "http://garage:3903"
 | 
			
		||||
      S3_ENDPOINT_URL: "http://garage:3900"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Without Docker
 | 
			
		||||
 | 
			
		||||
Get the latest binary from the [release page](https://github.com/khairul169/garage-webui/releases/latest) according to your OS architecture. For example:
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
$ wget -O garage-webui https://github.com/khairul169/garage-webui/releases/download/1.1.0/garage-webui-v1.1.0-linux-amd64
 | 
			
		||||
$ chmod +x garage-webui
 | 
			
		||||
$ sudo cp garage-webui /usr/local/bin
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Run the program with specified `garage.toml` config path.
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
$ CONFIG_PATH=./garage.toml garage-webui
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
If you want to run the program at startup, you may want to create a systemd service.
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
$ sudo nano /etc/systemd/system/garage-webui.service
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
[Unit]
 | 
			
		||||
Description=Garage Web UI
 | 
			
		||||
After=network.target
 | 
			
		||||
 | 
			
		||||
[Service]
 | 
			
		||||
Environment="PORT=3919"
 | 
			
		||||
Environment="CONFIG_PATH=/etc/garage.toml"
 | 
			
		||||
ExecStart=/usr/local/bin/garage-webui
 | 
			
		||||
Restart=always
 | 
			
		||||
 | 
			
		||||
[Install]
 | 
			
		||||
WantedBy=default.target
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Then reload and start the garage-webui service.
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
$ sudo systemctl daemon-reload
 | 
			
		||||
$ sudo systemctl enable --now garage-webui
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Configuration
 | 
			
		||||
@ -77,11 +136,39 @@ admin_token = "YOUR_ADMIN_TOKEN_HERE"
 | 
			
		||||
metrics_token = "YOUR_METRICS_TOKEN_HERE"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
However, if it fails to load, you can set these environment variables instead:
 | 
			
		||||
However, if it fails to load, you can set `API_BASE_URL` & `API_ADMIN_KEY` environment variables instead.
 | 
			
		||||
 | 
			
		||||
### Environment Variables
 | 
			
		||||
 | 
			
		||||
Configurable envs:
 | 
			
		||||
 | 
			
		||||
- `CONFIG_PATH`: Path to the Garage `config.toml` file. Defaults to `/etc/garage.toml`.
 | 
			
		||||
- `BASE_PATH`: Base path or prefix for Web UI.
 | 
			
		||||
- `API_BASE_URL`: Garage admin API endpoint URL.
 | 
			
		||||
- `API_ADMIN_KEY`: Admin API key.
 | 
			
		||||
- `S3_REGION`: S3 Region.
 | 
			
		||||
- `S3_ENDPOINT_URL`: S3 Endpoint url.
 | 
			
		||||
 | 
			
		||||
### Authentication
 | 
			
		||||
 | 
			
		||||
Enable authentication by setting the `AUTH_USER_PASS` environment variable in the format `username:password_hash`, where `password_hash` is a bcrypt hash of the password.
 | 
			
		||||
 | 
			
		||||
Generate the username and password hash using the following command:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
htpasswd -nbBC 10 "YOUR_USERNAME" "YOUR_PASSWORD"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
> If command 'htpasswd' is not found, install `apache2-utils` using your package manager.
 | 
			
		||||
 | 
			
		||||
Then update your `docker-compose.yml`:
 | 
			
		||||
 | 
			
		||||
```yml
 | 
			
		||||
webui:
 | 
			
		||||
  ....
 | 
			
		||||
  environment:
 | 
			
		||||
    AUTH_USER_PASS: "username:$2y$10$DSTi9o..."
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Running
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										6
									
								
								backend/.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								backend/.env.example
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,6 @@
 | 
			
		||||
#
 | 
			
		||||
BASE_PATH=""
 | 
			
		||||
AUTH_USER_PASS='username:$2y$10$DSTi9o0uQPEHSNlf66xMEOgm9KgVNBP3vHxA3SK0Xha2EVMb3mTXm'
 | 
			
		||||
API_BASE_URL="http://garage:3903"
 | 
			
		||||
S3_ENDPOINT_URL="http://garage:3900"
 | 
			
		||||
API_ADMIN_KEY=""
 | 
			
		||||
@ -1,8 +1,28 @@
 | 
			
		||||
module khairul169/garage-webui
 | 
			
		||||
 | 
			
		||||
go 1.22.5
 | 
			
		||||
go 1.23.0
 | 
			
		||||
 | 
			
		||||
toolchain go1.24.0
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	github.com/joho/godotenv v1.5.1 // indirect
 | 
			
		||||
	github.com/pelletier/go-toml/v2 v2.2.2 // indirect
 | 
			
		||||
	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/aws/smithy-go v1.20.4
 | 
			
		||||
	github.com/joho/godotenv v1.5.1
 | 
			
		||||
	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
 | 
			
		||||
	github.com/pelletier/go-toml/v2 v2.2.2
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	github.com/alexedwards/scs/v2 v2.8.0 // indirect
 | 
			
		||||
	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 // 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.11.4 // 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
 | 
			
		||||
	golang.org/x/crypto v0.35.0
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,39 @@
 | 
			
		||||
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
 | 
			
		||||
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
 | 
			
		||||
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/aws/protocol/eventstream v1.6.4 h1:70PVAiL15/aBMh5LThwgXdSQorVr91L127ttckI9QQU=
 | 
			
		||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4/go.mod h1:/MQxMqci8tlqDH+pjmoLu1i0tbWCUP1hhyMRuFxpQCw=
 | 
			
		||||
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/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/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/service/internal/accept-encoding v1.11.4 h1:KypMCbLPPHEmf9DgMGw51jMj77VfGPAN2Kv4cfhlfgI=
 | 
			
		||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4/go.mod h1:Vz1JQXliGcQktFTN/LN6uGppAIRoLBR2bMvIMP0gOjc=
 | 
			
		||||
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/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/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/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.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4=
 | 
			
		||||
github.com/aws/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
 | 
			
		||||
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
 | 
			
		||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
 | 
			
		||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
 | 
			
		||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
 | 
			
		||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
 | 
			
		||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
 | 
			
		||||
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 | 
			
		||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 | 
			
		||||
@ -12,7 +42,11 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
 | 
			
		||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 | 
			
		||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 | 
			
		||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 | 
			
		||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 | 
			
		||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 | 
			
		||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
 | 
			
		||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
 | 
			
		||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 | 
			
		||||
 | 
			
		||||
@ -7,22 +7,35 @@ import (
 | 
			
		||||
	"khairul169/garage-webui/utils"
 | 
			
		||||
	"log"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
 | 
			
		||||
	"github.com/joho/godotenv"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
	// Initialize app
 | 
			
		||||
	godotenv.Load()
 | 
			
		||||
	utils.InitCacheManager()
 | 
			
		||||
	sessionMgr := utils.InitSessionManager()
 | 
			
		||||
 | 
			
		||||
	if err := utils.Garage.LoadConfig(); err != nil {
 | 
			
		||||
		log.Fatal("Failed to load config! ", err)
 | 
			
		||||
		log.Println("Cannot load garage config!", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	http.HandleFunc("/api/config", router.GetConfig)
 | 
			
		||||
	http.HandleFunc("/api/buckets", router.GetAllBuckets)
 | 
			
		||||
	http.HandleFunc("/api/*", router.ProxyHandler)
 | 
			
		||||
	basePath := os.Getenv("BASE_PATH")
 | 
			
		||||
	mux := http.NewServeMux()
 | 
			
		||||
 | 
			
		||||
	ui.ServeUI()
 | 
			
		||||
	// Serve API
 | 
			
		||||
	apiPrefix := basePath + "/api"
 | 
			
		||||
	mux.Handle(apiPrefix+"/", http.StripPrefix(apiPrefix, router.HandleApiRouter()))
 | 
			
		||||
 | 
			
		||||
	// Static files
 | 
			
		||||
	ui.ServeUI(mux)
 | 
			
		||||
 | 
			
		||||
	// Redirect to UI if BASE_PATH is set
 | 
			
		||||
	if basePath != "" {
 | 
			
		||||
		mux.Handle("/", http.RedirectHandler(basePath, http.StatusMovedPermanently))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	host := utils.GetEnv("HOST", "0.0.0.0")
 | 
			
		||||
	port := utils.GetEnv("PORT", "3909")
 | 
			
		||||
@ -30,7 +43,7 @@ func main() {
 | 
			
		||||
	addr := fmt.Sprintf("%s:%s", host, port)
 | 
			
		||||
	log.Printf("Starting server on http://%s", addr)
 | 
			
		||||
 | 
			
		||||
	if err := http.ListenAndServe(addr, nil); err != nil {
 | 
			
		||||
	if err := http.ListenAndServe(addr, sessionMgr.LoadAndSave(mux)); err != nil {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										27
									
								
								backend/middleware/auth.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								backend/middleware/auth.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,27 @@
 | 
			
		||||
package middleware
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"khairul169/garage-webui/utils"
 | 
			
		||||
	"net/http"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func AuthMiddleware(next http.Handler) http.Handler {
 | 
			
		||||
	authData := utils.GetEnv("AUTH_USER_PASS", "")
 | 
			
		||||
 | 
			
		||||
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		auth := utils.Session.Get(r, "authenticated")
 | 
			
		||||
 | 
			
		||||
		if authData == "" {
 | 
			
		||||
			next.ServeHTTP(w, r)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if auth == nil || !auth.(bool) {
 | 
			
		||||
			utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		next.ServeHTTP(w, r)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										64
									
								
								backend/router/auth.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								backend/router/auth.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,64 @@
 | 
			
		||||
package router
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"khairul169/garage-webui/utils"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"golang.org/x/crypto/bcrypt"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type Auth struct{}
 | 
			
		||||
 | 
			
		||||
func (c *Auth) Login(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	var body struct {
 | 
			
		||||
		Username string `json:"username"`
 | 
			
		||||
		Password string `json:"password"`
 | 
			
		||||
	}
 | 
			
		||||
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
 | 
			
		||||
		utils.ResponseError(w, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	userPass := strings.Split(utils.GetEnv("AUTH_USER_PASS", ""), ":")
 | 
			
		||||
	if len(userPass) < 2 {
 | 
			
		||||
		utils.ResponseErrorStatus(w, errors.New("AUTH_USER_PASS not set"), 500)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if strings.TrimSpace(body.Username) != userPass[0] || bcrypt.CompareHashAndPassword([]byte(userPass[1]), []byte(body.Password)) != nil {
 | 
			
		||||
		utils.ResponseErrorStatus(w, errors.New("invalid username or password"), 401)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	utils.Session.Set(r, "authenticated", true)
 | 
			
		||||
	utils.ResponseSuccess(w, map[string]bool{
 | 
			
		||||
		"authenticated": true,
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *Auth) Logout(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	utils.Session.Clear(r)
 | 
			
		||||
	utils.ResponseSuccess(w, true)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *Auth) GetStatus(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	isAuthenticated := true
 | 
			
		||||
	authSession := utils.Session.Get(r, "authenticated")
 | 
			
		||||
	enabled := false
 | 
			
		||||
 | 
			
		||||
	if utils.GetEnv("AUTH_USER_PASS", "") != "" {
 | 
			
		||||
		enabled = true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if authSession != nil && authSession.(bool) {
 | 
			
		||||
		isAuthenticated = true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	utils.ResponseSuccess(w, map[string]bool{
 | 
			
		||||
		"enabled":       enabled,
 | 
			
		||||
		"authenticated": isAuthenticated,
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										357
									
								
								backend/router/browse.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										357
									
								
								backend/router/browse.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,357 @@
 | 
			
		||||
package router
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"khairul169/garage-webui/schema"
 | 
			
		||||
	"khairul169/garage-webui/utils"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/aws/aws-sdk-go-v2/aws"
 | 
			
		||||
	"github.com/aws/aws-sdk-go-v2/credentials"
 | 
			
		||||
	"github.com/aws/aws-sdk-go-v2/service/s3"
 | 
			
		||||
	"github.com/aws/aws-sdk-go-v2/service/s3/types"
 | 
			
		||||
	"github.com/aws/smithy-go"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type Browse struct{}
 | 
			
		||||
 | 
			
		||||
func (b *Browse) GetObjects(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	query := r.URL.Query()
 | 
			
		||||
	bucket := r.PathValue("bucket")
 | 
			
		||||
	prefix := query.Get("prefix")
 | 
			
		||||
	continuationToken := query.Get("next")
 | 
			
		||||
 | 
			
		||||
	limit, err := strconv.Atoi(query.Get("limit"))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		limit = 100
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	client, err := getS3Client(bucket)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.ResponseError(w, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	objects, err := client.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{
 | 
			
		||||
		Bucket:            aws.String(bucket),
 | 
			
		||||
		Prefix:            aws.String(prefix),
 | 
			
		||||
		Delimiter:         aws.String("/"),
 | 
			
		||||
		MaxKeys:           aws.Int32(int32(limit)),
 | 
			
		||||
		ContinuationToken: aws.String(continuationToken),
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.ResponseError(w, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	result := schema.BrowseObjectResult{
 | 
			
		||||
		Prefixes:  []string{},
 | 
			
		||||
		Objects:   []schema.BrowserObject{},
 | 
			
		||||
		Prefix:    prefix,
 | 
			
		||||
		NextToken: objects.NextContinuationToken,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, prefix := range objects.CommonPrefixes {
 | 
			
		||||
		result.Prefixes = append(result.Prefixes, *prefix.Prefix)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, object := range objects.Contents {
 | 
			
		||||
		key := strings.TrimPrefix(*object.Key, prefix)
 | 
			
		||||
		if key == "" {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		result.Objects = append(result.Objects, schema.BrowserObject{
 | 
			
		||||
			ObjectKey:    &key,
 | 
			
		||||
			LastModified: object.LastModified,
 | 
			
		||||
			Size:         object.Size,
 | 
			
		||||
			Url:          fmt.Sprintf("/browse/%s/%s", bucket, *object.Key),
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	utils.ResponseSuccess(w, result)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (b *Browse) GetOneObject(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	bucket := r.PathValue("bucket")
 | 
			
		||||
	key := r.PathValue("key")
 | 
			
		||||
	queryParams := r.URL.Query()
 | 
			
		||||
	view := queryParams.Get("view") == "1"
 | 
			
		||||
	thumbnail := queryParams.Get("thumb") == "1"
 | 
			
		||||
	download := queryParams.Get("dl") == "1"
 | 
			
		||||
 | 
			
		||||
	client, err := getS3Client(bucket)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.ResponseError(w, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !view && !download && !thumbnail {
 | 
			
		||||
		object, err := client.HeadObject(context.Background(), &s3.HeadObjectInput{
 | 
			
		||||
			Bucket: aws.String(bucket),
 | 
			
		||||
			Key:    aws.String(key),
 | 
			
		||||
		})
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			utils.ResponseError(w, err)
 | 
			
		||||
		}
 | 
			
		||||
		utils.ResponseSuccess(w, object)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	object, err := client.GetObject(context.Background(), &s3.GetObjectInput{
 | 
			
		||||
		Bucket: aws.String(bucket),
 | 
			
		||||
		Key:    aws.String(key),
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		var ae smithy.APIError
 | 
			
		||||
		if errors.As(err, &ae) && ae.ErrorCode() == "NoSuchKey" {
 | 
			
		||||
			utils.ResponseErrorStatus(w, err, http.StatusNotFound)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		utils.ResponseError(w, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	defer object.Body.Close()
 | 
			
		||||
	keys := strings.Split(key, "/")
 | 
			
		||||
 | 
			
		||||
	if download {
 | 
			
		||||
		w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", keys[len(keys)-1]))
 | 
			
		||||
	} else if thumbnail {
 | 
			
		||||
		body, err := io.ReadAll(object.Body)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			utils.ResponseError(w, err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		thumb, err := utils.CreateThumbnailImage(body, 64, 64)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
 | 
			
		||||
			utils.ResponseError(w, err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		w.Header().Set("Content-Type", "image/png")
 | 
			
		||||
		w.Write(thumb)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Cache-Control", "max-age=86400")
 | 
			
		||||
	w.Header().Set("Last-Modified", object.LastModified.Format(time.RFC1123))
 | 
			
		||||
 | 
			
		||||
	if object.ContentType != nil {
 | 
			
		||||
		w.Header().Set("Content-Type", *object.ContentType)
 | 
			
		||||
	} else {
 | 
			
		||||
		w.Header().Set("Content-Type", "application/octet-stream")
 | 
			
		||||
	}
 | 
			
		||||
	if object.ContentLength != nil {
 | 
			
		||||
		w.Header().Set("Content-Length", strconv.FormatInt(*object.ContentLength, 10))
 | 
			
		||||
	}
 | 
			
		||||
	if object.ETag != nil {
 | 
			
		||||
		w.Header().Set("Etag", *object.ETag)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err = io.Copy(w, object.Body)
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.ResponseError(w, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (b *Browse) PutObject(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	bucket := r.PathValue("bucket")
 | 
			
		||||
	key := r.PathValue("key")
 | 
			
		||||
	isDirectory := strings.HasSuffix(key, "/")
 | 
			
		||||
 | 
			
		||||
	file, headers, err := r.FormFile("file")
 | 
			
		||||
	if err != nil && !isDirectory {
 | 
			
		||||
		utils.ResponseError(w, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if file != nil {
 | 
			
		||||
		defer file.Close()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	client, err := getS3Client(bucket)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.ResponseError(w, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var contentType string = ""
 | 
			
		||||
	var size int64 = 0
 | 
			
		||||
 | 
			
		||||
	if file != nil {
 | 
			
		||||
		contentType = headers.Header.Get("Content-Type")
 | 
			
		||||
		size = headers.Size
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	result, err := client.PutObject(context.Background(), &s3.PutObjectInput{
 | 
			
		||||
		Bucket:        aws.String(bucket),
 | 
			
		||||
		Key:           aws.String(key),
 | 
			
		||||
		Body:          file,
 | 
			
		||||
		ContentLength: aws.Int64(size),
 | 
			
		||||
		ContentType:   aws.String(contentType),
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.ResponseError(w, fmt.Errorf("cannot put object: %w", err))
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	utils.ResponseSuccess(w, result)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (b *Browse) DeleteObject(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	bucket := r.PathValue("bucket")
 | 
			
		||||
	key := r.PathValue("key")
 | 
			
		||||
	recursive := r.URL.Query().Get("recursive") == "true"
 | 
			
		||||
	isDirectory := strings.HasSuffix(key, "/")
 | 
			
		||||
 | 
			
		||||
	client, err := getS3Client(bucket)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.ResponseError(w, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Delete directory and its content
 | 
			
		||||
	if isDirectory && recursive {
 | 
			
		||||
		objects, err := client.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{
 | 
			
		||||
			Bucket: aws.String(bucket),
 | 
			
		||||
			Prefix: aws.String(key),
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			utils.ResponseError(w, err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if len(objects.Contents) == 0 {
 | 
			
		||||
			utils.ResponseSuccess(w, true)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		keys := make([]types.ObjectIdentifier, 0, len(objects.Contents))
 | 
			
		||||
 | 
			
		||||
		for _, object := range objects.Contents {
 | 
			
		||||
			keys = append(keys, types.ObjectIdentifier{
 | 
			
		||||
				Key: object.Key,
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		res, err := client.DeleteObjects(context.Background(), &s3.DeleteObjectsInput{
 | 
			
		||||
			Bucket: aws.String(bucket),
 | 
			
		||||
			Delete: &types.Delete{Objects: keys},
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			utils.ResponseError(w, fmt.Errorf("cannot delete object: %w", err))
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if len(res.Errors) > 0 {
 | 
			
		||||
			utils.ResponseError(w, fmt.Errorf("cannot delete object: %v", res.Errors[0]))
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		utils.ResponseSuccess(w, res)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Delete single object
 | 
			
		||||
	res, err := client.DeleteObject(context.Background(), &s3.DeleteObjectInput{
 | 
			
		||||
		Bucket: aws.String(bucket),
 | 
			
		||||
		Key:    aws.String(key),
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.ResponseError(w, fmt.Errorf("cannot delete object: %w", err))
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	utils.ResponseSuccess(w, res)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) {
 | 
			
		||||
	cacheKey := fmt.Sprintf("key:%s", bucket)
 | 
			
		||||
	cacheData := utils.Cache.Get(cacheKey)
 | 
			
		||||
 | 
			
		||||
	if cacheData != nil {
 | 
			
		||||
		return cacheData.(aws.CredentialsProvider), nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	body, err := utils.Garage.Fetch("/v2/GetBucketInfo?globalAlias="+bucket, &utils.FetchOptions{})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var bucketData schema.Bucket
 | 
			
		||||
	if err := json.Unmarshal(body, &bucketData); err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var key schema.KeyElement
 | 
			
		||||
 | 
			
		||||
	for _, k := range bucketData.Keys {
 | 
			
		||||
		if !k.Permissions.Read || !k.Permissions.Write {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		body, err := utils.Garage.Fetch(fmt.Sprintf("/v2/GetKeyInfo?id=%s&showSecretKey=true", k.AccessKeyID), &utils.FetchOptions{})
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
		if err := json.Unmarshal(body, &key); err != nil {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
		break
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	credential := credentials.NewStaticCredentialsProvider(key.AccessKeyID, key.SecretAccessKey, "")
 | 
			
		||||
	utils.Cache.Set(cacheKey, credential, time.Hour)
 | 
			
		||||
 | 
			
		||||
	return credential, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getS3Client(bucket string) (*s3.Client, error) {
 | 
			
		||||
	creds, err := getBucketCredentials(bucket)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucket, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Determine endpoint and whether to disable HTTPS
 | 
			
		||||
	endpoint := utils.Garage.GetS3Endpoint()
 | 
			
		||||
	disableHTTPS := !strings.HasPrefix(endpoint, "https://")
 | 
			
		||||
 | 
			
		||||
	// AWS config without BaseEndpoint
 | 
			
		||||
	awsConfig := aws.Config{
 | 
			
		||||
		Credentials: creds,
 | 
			
		||||
		Region:      utils.Garage.GetS3Region(),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Build S3 client with custom endpoint resolver for proper signing
 | 
			
		||||
	client := s3.NewFromConfig(awsConfig, func(o *s3.Options) {
 | 
			
		||||
		o.UsePathStyle = true
 | 
			
		||||
		o.EndpointOptions.DisableHTTPS = disableHTTPS
 | 
			
		||||
		o.EndpointResolver = s3.EndpointResolverFunc(func(region string, opts s3.EndpointResolverOptions) (aws.Endpoint, error) {
 | 
			
		||||
			return aws.Endpoint{
 | 
			
		||||
				URL:           endpoint,
 | 
			
		||||
				SigningRegion: utils.Garage.GetS3Region(),
 | 
			
		||||
			}, nil
 | 
			
		||||
		})
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	return client, nil
 | 
			
		||||
}
 | 
			
		||||
@ -8,8 +8,10 @@ import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func GetAllBuckets(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	body, err := utils.Garage.Fetch("/v1/bucket?list", &utils.FetchOptions{})
 | 
			
		||||
type Buckets struct{}
 | 
			
		||||
 | 
			
		||||
func (b *Buckets) GetAll(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	body, err := utils.Garage.Fetch("/v2/ListBuckets", &utils.FetchOptions{})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.ResponseError(w, err)
 | 
			
		||||
		return
 | 
			
		||||
@ -25,20 +27,21 @@ func GetAllBuckets(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
 | 
			
		||||
	for _, bucket := range buckets {
 | 
			
		||||
		go func() {
 | 
			
		||||
			body, err := utils.Garage.Fetch(fmt.Sprintf("/v1/bucket?id=%s", bucket.ID), &utils.FetchOptions{})
 | 
			
		||||
			body, err := utils.Garage.Fetch(fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucket.ID), &utils.FetchOptions{})
 | 
			
		||||
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				ch <- schema.Bucket{ID: bucket.ID, GlobalAliases: bucket.GlobalAliases}
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var bucket schema.Bucket
 | 
			
		||||
			if err := json.Unmarshal(body, &bucket); err != nil {
 | 
			
		||||
			var data schema.Bucket
 | 
			
		||||
			if err := json.Unmarshal(body, &data); err != nil {
 | 
			
		||||
				ch <- schema.Bucket{ID: bucket.ID, GlobalAliases: bucket.GlobalAliases}
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			ch <- bucket
 | 
			
		||||
			data.LocalAliases = bucket.LocalAliases
 | 
			
		||||
			ch <- data
 | 
			
		||||
		}()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,9 @@ import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func GetConfig(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
type Config struct{}
 | 
			
		||||
 | 
			
		||||
func (c *Config) GetAll(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	config := utils.Garage.Config
 | 
			
		||||
	utils.ResponseSuccess(w, config)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										35
									
								
								backend/router/router.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								backend/router/router.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,35 @@
 | 
			
		||||
package router
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"khairul169/garage-webui/middleware"
 | 
			
		||||
	"net/http"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func HandleApiRouter() *http.ServeMux {
 | 
			
		||||
	mux := http.NewServeMux()
 | 
			
		||||
 | 
			
		||||
	auth := &Auth{}
 | 
			
		||||
	mux.HandleFunc("POST /auth/login", auth.Login)
 | 
			
		||||
 | 
			
		||||
	router := http.NewServeMux()
 | 
			
		||||
	router.HandleFunc("POST /auth/logout", auth.Logout)
 | 
			
		||||
	router.HandleFunc("GET /auth/status", auth.GetStatus)
 | 
			
		||||
 | 
			
		||||
	config := &Config{}
 | 
			
		||||
	router.HandleFunc("GET /config", config.GetAll)
 | 
			
		||||
 | 
			
		||||
	buckets := &Buckets{}
 | 
			
		||||
	router.HandleFunc("GET /buckets", buckets.GetAll)
 | 
			
		||||
 | 
			
		||||
	browse := &Browse{}
 | 
			
		||||
	router.HandleFunc("GET /browse/{bucket}", browse.GetObjects)
 | 
			
		||||
	router.HandleFunc("GET /browse/{bucket}/{key...}", browse.GetOneObject)
 | 
			
		||||
	router.HandleFunc("PUT /browse/{bucket}/{key...}", browse.PutObject)
 | 
			
		||||
	router.HandleFunc("DELETE /browse/{bucket}/{key...}", browse.DeleteObject)
 | 
			
		||||
 | 
			
		||||
	// Proxy request to garage api endpoint
 | 
			
		||||
	router.HandleFunc("/", ProxyHandler)
 | 
			
		||||
 | 
			
		||||
	mux.Handle("/", middleware.AuthMiddleware(router))
 | 
			
		||||
	return mux
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										17
									
								
								backend/schema/browse.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								backend/schema/browse.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
			
		||||
package schema
 | 
			
		||||
 | 
			
		||||
import "time"
 | 
			
		||||
 | 
			
		||||
type BrowseObjectResult struct {
 | 
			
		||||
	Prefixes  []string        `json:"prefixes"`
 | 
			
		||||
	Objects   []BrowserObject `json:"objects"`
 | 
			
		||||
	Prefix    string          `json:"prefix"`
 | 
			
		||||
	NextToken *string         `json:"nextToken"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type BrowserObject struct {
 | 
			
		||||
	ObjectKey    *string    `json:"objectKey"`
 | 
			
		||||
	LastModified *time.Time `json:"lastModified"`
 | 
			
		||||
	Size         *int64     `json:"size"`
 | 
			
		||||
	Url          string     `json:"url"`
 | 
			
		||||
}
 | 
			
		||||
@ -1,14 +1,16 @@
 | 
			
		||||
package schema
 | 
			
		||||
 | 
			
		||||
type GetBucketsRes struct {
 | 
			
		||||
	ID            string   `json:"id"`
 | 
			
		||||
	GlobalAliases []string `json:"globalAliases"`
 | 
			
		||||
	LocalAliases  []string `json:"localAliases"`
 | 
			
		||||
	ID            string       `json:"id"`
 | 
			
		||||
	GlobalAliases []string     `json:"globalAliases"`
 | 
			
		||||
	LocalAliases  []LocalAlias `json:"localAliases"`
 | 
			
		||||
	Created       string       `json:"created"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Bucket struct {
 | 
			
		||||
	ID                             string        `json:"id"`
 | 
			
		||||
	GlobalAliases                  []string      `json:"globalAliases"`
 | 
			
		||||
	LocalAliases                   []LocalAlias  `json:"localAliases"`
 | 
			
		||||
	WebsiteAccess                  bool          `json:"websiteAccess"`
 | 
			
		||||
	WebsiteConfig                  WebsiteConfig `json:"websiteConfig"`
 | 
			
		||||
	Keys                           []KeyElement  `json:"keys"`
 | 
			
		||||
@ -19,13 +21,20 @@ type Bucket struct {
 | 
			
		||||
	UnfinishedMultipartUploadParts int64         `json:"unfinishedMultipartUploadParts"`
 | 
			
		||||
	UnfinishedMultipartUploadBytes int64         `json:"unfinishedMultipartUploadBytes"`
 | 
			
		||||
	Quotas                         Quotas        `json:"quotas"`
 | 
			
		||||
	Created                        string        `json:"created"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type LocalAlias struct {
 | 
			
		||||
	AccessKeyID string `json:"accessKeyId"`
 | 
			
		||||
	Alias       string `json:"alias"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type KeyElement struct {
 | 
			
		||||
	AccessKeyID        string        `json:"accessKeyId"`
 | 
			
		||||
	Name               string        `json:"name"`
 | 
			
		||||
	Permissions        Permissions   `json:"permissions"`
 | 
			
		||||
	BucketLocalAliases []interface{} `json:"bucketLocalAliases"`
 | 
			
		||||
	AccessKeyID        string      `json:"accessKeyId"`
 | 
			
		||||
	Name               string      `json:"name"`
 | 
			
		||||
	Permissions        Permissions `json:"permissions"`
 | 
			
		||||
	BucketLocalAliases []string    `json:"bucketLocalAliases"`
 | 
			
		||||
	SecretAccessKey    string      `json:"secretAccessKey"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Permissions struct {
 | 
			
		||||
 | 
			
		||||
@ -1,18 +1,12 @@
 | 
			
		||||
package schema
 | 
			
		||||
 | 
			
		||||
type Config struct {
 | 
			
		||||
	CompressionLevel             int64  `json:"compression_level" toml:"compression_level"`
 | 
			
		||||
	DataDir                      string `json:"data_dir" toml:"data_dir"`
 | 
			
		||||
	DBEngine                     string `json:"db_engine" toml:"db_engine"`
 | 
			
		||||
	MetadataAutoSnapshotInterval string `json:"metadata_auto_snapshot_interval" toml:"metadata_auto_snapshot_interval"`
 | 
			
		||||
	MetadataDir                  string `json:"metadata_dir" toml:"metadata_dir"`
 | 
			
		||||
	ReplicationFactor            int64  `json:"replication_factor" toml:"replication_factor"`
 | 
			
		||||
	RPCBindAddr                  string `json:"rpc_bind_addr" toml:"rpc_bind_addr"`
 | 
			
		||||
	RPCPublicAddr                string `json:"rpc_public_addr" toml:"rpc_public_addr"`
 | 
			
		||||
	RPCSecret                    string `json:"rpc_secret" toml:"rpc_secret"`
 | 
			
		||||
	Admin                        Admin  `json:"admin" toml:"admin"`
 | 
			
		||||
	S3API                        S3API  `json:"s3_api" toml:"s3_api"`
 | 
			
		||||
	S3Web                        S3Web  `json:"s3_web" toml:"s3_web"`
 | 
			
		||||
	RPCBindAddr   string `json:"rpc_bind_addr" toml:"rpc_bind_addr"`
 | 
			
		||||
	RPCPublicAddr string `json:"rpc_public_addr" toml:"rpc_public_addr"`
 | 
			
		||||
	RPCSecret     string `json:"rpc_secret" toml:"rpc_secret"`
 | 
			
		||||
	Admin         Admin  `json:"admin" toml:"admin"`
 | 
			
		||||
	S3API         S3API  `json:"s3_api" toml:"s3_api"`
 | 
			
		||||
	S3Web         S3Web  `json:"s3_web" toml:"s3_web"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Admin struct {
 | 
			
		||||
 | 
			
		||||
@ -3,4 +3,6 @@
 | 
			
		||||
 | 
			
		||||
package ui
 | 
			
		||||
 | 
			
		||||
func ServeUI() {}
 | 
			
		||||
import "net/http"
 | 
			
		||||
 | 
			
		||||
func ServeUI(mux *http.ServeMux) {}
 | 
			
		||||
 | 
			
		||||
@ -7,28 +7,57 @@ import (
 | 
			
		||||
	"embed"
 | 
			
		||||
	"io/fs"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path"
 | 
			
		||||
	"regexp"
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
//go:embed dist
 | 
			
		||||
var embeddedFs embed.FS
 | 
			
		||||
 | 
			
		||||
func ServeUI() {
 | 
			
		||||
func ServeUI(mux *http.ServeMux) {
 | 
			
		||||
	distFs, _ := fs.Sub(embeddedFs, "dist")
 | 
			
		||||
	fileServer := http.FileServer(http.FS(distFs))
 | 
			
		||||
	basePath := os.Getenv("BASE_PATH")
 | 
			
		||||
 | 
			
		||||
	http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	mux.Handle(basePath+"/", http.StripPrefix(basePath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		_path := path.Clean(r.URL.Path)[1:]
 | 
			
		||||
 | 
			
		||||
		// Rewrite non-existing paths to index.html
 | 
			
		||||
		if _, err := fs.Stat(distFs, _path); err != nil {
 | 
			
		||||
			index, _ := fs.ReadFile(distFs, "index.html")
 | 
			
		||||
			html := string(index)
 | 
			
		||||
 | 
			
		||||
			// Set base path for the UI
 | 
			
		||||
			html = strings.ReplaceAll(html, "%BASE_PATH%", basePath)
 | 
			
		||||
			html = addBasePath(html, basePath)
 | 
			
		||||
 | 
			
		||||
			w.Header().Add("Content-Type", "text/html")
 | 
			
		||||
			w.WriteHeader(http.StatusOK)
 | 
			
		||||
			w.Write(index)
 | 
			
		||||
			w.Write([]byte(html))
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Add prefix to each /assets strings in js
 | 
			
		||||
		if len(basePath) > 0 && strings.HasSuffix(_path, ".js") {
 | 
			
		||||
			data, _ := fs.ReadFile(distFs, _path)
 | 
			
		||||
			html := string(data)
 | 
			
		||||
			html = strings.ReplaceAll(html, "assets/", basePath[1:]+"/assets/")
 | 
			
		||||
 | 
			
		||||
			w.Header().Add("Content-Type", "text/javascript")
 | 
			
		||||
			w.WriteHeader(http.StatusOK)
 | 
			
		||||
			w.Write([]byte(html))
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fileServer.ServeHTTP(w, r)
 | 
			
		||||
	}))
 | 
			
		||||
	})))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func addBasePath(html string, basePath string) string {
 | 
			
		||||
	re := regexp.MustCompile(`(href|src)=["'](/[^"'>]+)["']`)
 | 
			
		||||
	return re.ReplaceAllStringFunc(html, func(match string) string {
 | 
			
		||||
		return re.ReplaceAllString(match, `$1="`+basePath+`$2"`)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										49
									
								
								backend/utils/cache.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								backend/utils/cache.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,49 @@
 | 
			
		||||
package utils
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type CacheEntry struct {
 | 
			
		||||
	value     interface{}
 | 
			
		||||
	expiresAt time.Time
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type CacheManager struct {
 | 
			
		||||
	cache *sync.Map
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var Cache *CacheManager
 | 
			
		||||
 | 
			
		||||
func InitCacheManager() {
 | 
			
		||||
	Cache = &CacheManager{
 | 
			
		||||
		cache: &sync.Map{},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *CacheManager) Set(key string, value interface{}, ttl time.Duration) {
 | 
			
		||||
	c.cache.Store(key, CacheEntry{
 | 
			
		||||
		value:     value,
 | 
			
		||||
		expiresAt: time.Now().Add(ttl),
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *CacheManager) Get(key string) interface{} {
 | 
			
		||||
	entry, ok := c.cache.Load(key)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cacheEntry := entry.(CacheEntry)
 | 
			
		||||
	if cacheEntry.expiresAt.Before(time.Now()) {
 | 
			
		||||
		c.cache.Delete(key)
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return cacheEntry.value
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *CacheManager) IsExpired(entry CacheEntry) bool {
 | 
			
		||||
	return entry.expiresAt.Before(time.Now())
 | 
			
		||||
}
 | 
			
		||||
@ -3,6 +3,7 @@ package utils
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"khairul169/garage-webui/schema"
 | 
			
		||||
@ -56,6 +57,34 @@ func (g *garage) GetAdminEndpoint() string {
 | 
			
		||||
	return endpoint
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *garage) GetS3Endpoint() string {
 | 
			
		||||
	endpoint := os.Getenv("S3_ENDPOINT_URL")
 | 
			
		||||
	if len(endpoint) > 0 {
 | 
			
		||||
		return endpoint
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	host := strings.Split(g.Config.RPCPublicAddr, ":")[0]
 | 
			
		||||
	port := LastString(strings.Split(g.Config.S3API.APIBindAddr, ":"))
 | 
			
		||||
 | 
			
		||||
	endpoint = fmt.Sprintf("%s:%s", host, port)
 | 
			
		||||
	if !strings.HasPrefix(endpoint, "http") {
 | 
			
		||||
		endpoint = fmt.Sprintf("http://%s", endpoint)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return endpoint
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *garage) GetS3Region() string {
 | 
			
		||||
	endpoint := os.Getenv("S3_REGION")
 | 
			
		||||
	if len(endpoint) > 0 {
 | 
			
		||||
		return endpoint
 | 
			
		||||
	}
 | 
			
		||||
	if len(g.Config.S3API.S3Region) == 0 {
 | 
			
		||||
		return "garage"
 | 
			
		||||
	}
 | 
			
		||||
	return g.Config.S3API.S3Region
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (g *garage) GetAdminKey() string {
 | 
			
		||||
	key := os.Getenv("API_ADMIN_KEY")
 | 
			
		||||
	if len(key) > 0 {
 | 
			
		||||
@ -136,7 +165,7 @@ func (g *garage) Fetch(url string, options *FetchOptions) ([]byte, error) {
 | 
			
		||||
			message = fmt.Sprintf("%v", data["message"])
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return nil, fmt.Errorf(message)
 | 
			
		||||
		return nil, errors.New(message)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	body, err := io.ReadAll(res.Body)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										26
									
								
								backend/utils/image.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								backend/utils/image.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
			
		||||
package utils
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"image"
 | 
			
		||||
	_ "image/gif"
 | 
			
		||||
	"image/jpeg"
 | 
			
		||||
	_ "image/png"
 | 
			
		||||
 | 
			
		||||
	"github.com/nfnt/resize"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func CreateThumbnailImage(buffer []byte, width uint, height uint) ([]byte, error) {
 | 
			
		||||
	img, _, err := image.Decode(bytes.NewReader(buffer))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	thumb := resize.Thumbnail(width, height, img, resize.NearestNeighbor)
 | 
			
		||||
	buf := new(bytes.Buffer)
 | 
			
		||||
	if err := jpeg.Encode(buf, thumb, nil); err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return buf.Bytes(), nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										33
									
								
								backend/utils/session.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								backend/utils/session.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,33 @@
 | 
			
		||||
package utils
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/alexedwards/scs/v2"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type SessionManager struct {
 | 
			
		||||
	mgr *scs.SessionManager
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var Session *SessionManager
 | 
			
		||||
 | 
			
		||||
func InitSessionManager() *scs.SessionManager {
 | 
			
		||||
	sessMgr := scs.New()
 | 
			
		||||
	sessMgr.Lifetime = 24 * time.Hour
 | 
			
		||||
	Session = &SessionManager{mgr: sessMgr}
 | 
			
		||||
	return sessMgr
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s *SessionManager) Get(r *http.Request, key string) interface{} {
 | 
			
		||||
	return s.mgr.Get(r.Context(), key)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s *SessionManager) Set(r *http.Request, key string, value interface{}) {
 | 
			
		||||
	s.mgr.Put(r.Context(), key, value)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s *SessionManager) Clear(r *http.Request) error {
 | 
			
		||||
	return s.mgr.Clear(r.Context())
 | 
			
		||||
}
 | 
			
		||||
@ -23,6 +23,11 @@ func ResponseError(w http.ResponseWriter, err error) {
 | 
			
		||||
	w.Write([]byte(err.Error()))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func ResponseErrorStatus(w http.ResponseWriter, err error, status int) {
 | 
			
		||||
	w.WriteHeader(status)
 | 
			
		||||
	w.Write([]byte(err.Error()))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func ResponseSuccess(w http.ResponseWriter, data interface{}) {
 | 
			
		||||
	w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
	w.WriteHeader(http.StatusOK)
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,17 @@
 | 
			
		||||
services:
 | 
			
		||||
  garage:
 | 
			
		||||
    image: dxflrs/garage:v1.0.0
 | 
			
		||||
    image: dxflrs/garage:v2.0.0
 | 
			
		||||
    container_name: garage
 | 
			
		||||
    volumes:
 | 
			
		||||
      - ./garage.toml:/etc/garage.toml
 | 
			
		||||
      - ./meta:/var/lib/garage/meta
 | 
			
		||||
      - ./data:/var/lib/garage/data
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    network_mode: host
 | 
			
		||||
    ports:
 | 
			
		||||
      - 3900:3900
 | 
			
		||||
      - 3901:3901
 | 
			
		||||
      - 3902:3903
 | 
			
		||||
      - 3903:3903
 | 
			
		||||
 | 
			
		||||
  webui:
 | 
			
		||||
    image: khairul169/garage-webui:latest
 | 
			
		||||
@ -17,3 +21,6 @@ services:
 | 
			
		||||
      - ./garage.toml:/etc/garage.toml:ro
 | 
			
		||||
    ports:
 | 
			
		||||
      - 3909:3909
 | 
			
		||||
    environment:
 | 
			
		||||
      API_BASE_URL: "http://garage:3903"
 | 
			
		||||
      S3_ENDPOINT_URL: "http://garage:3900"
 | 
			
		||||
 | 
			
		||||
@ -10,6 +10,9 @@
 | 
			
		||||
    <link rel="manifest" href="/site.webmanifest" />
 | 
			
		||||
 | 
			
		||||
    <title>Garage Web UI</title>
 | 
			
		||||
    <script>
 | 
			
		||||
      window.__BASE_PATH = "%BASE_PATH%";
 | 
			
		||||
    </script>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body>
 | 
			
		||||
    <div id="root"></div>
 | 
			
		||||
 | 
			
		||||
@ -6,3 +6,12 @@
 | 
			
		||||
[](img/buckets-overview.png)
 | 
			
		||||
[](img/buckets-permissions.png)
 | 
			
		||||
[](img/keys.png)
 | 
			
		||||
[](img/buckets-browse.png)
 | 
			
		||||
[](img/buckets-browse-sharing.png)
 | 
			
		||||
 | 
			
		||||
### Mobile
 | 
			
		||||
 | 
			
		||||
[](img/mobile-dashboard.png)
 | 
			
		||||
[](img/mobile-cluster.png)
 | 
			
		||||
[](img/mobile-buckets.png)
 | 
			
		||||
[](img/mobile-bucket-browse.png)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										21
									
								
								misc/build-binaries.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										21
									
								
								misc/build-binaries.sh
									
									
									
									
									
										Executable file
									
								
							@ -0,0 +1,21 @@
 | 
			
		||||
#!/bin/sh
 | 
			
		||||
 | 
			
		||||
set -e
 | 
			
		||||
 | 
			
		||||
BINARY=garage-webui
 | 
			
		||||
VERSION=$(cat package.json | grep \"version\" | cut -d'"' -f 4)
 | 
			
		||||
PLATFORMS="linux"
 | 
			
		||||
ARCHITECTURES="386 amd64 arm arm64"
 | 
			
		||||
 | 
			
		||||
echo "Building version $VERSION"
 | 
			
		||||
 | 
			
		||||
npm run build
 | 
			
		||||
cd backend && rm -rf dist && mkdir -p dist && rm -rf ./ui/dist && cp -r ../dist ./ui/dist
 | 
			
		||||
 | 
			
		||||
for PLATFORM in $PLATFORMS; do
 | 
			
		||||
    for ARCH in $ARCHITECTURES; do
 | 
			
		||||
        echo "Building $PLATFORM-$ARCH"
 | 
			
		||||
 | 
			
		||||
        GOOS=$PLATFORM GOARCH=$ARCH go build -o "dist/$BINARY-v$VERSION-$PLATFORM-$ARCH" -tags="prod" main.go
 | 
			
		||||
    done
 | 
			
		||||
done
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								misc/img/buckets-browse-sharing.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								misc/img/buckets-browse-sharing.png
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 83 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								misc/img/buckets-browse.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								misc/img/buckets-browse.png
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 63 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								misc/img/mobile-bucket-browse.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								misc/img/mobile-bucket-browse.png
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 33 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								misc/img/mobile-buckets.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								misc/img/mobile-buckets.png
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 26 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								misc/img/mobile-cluster.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								misc/img/mobile-cluster.png
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 26 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								misc/img/mobile-dashboard.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								misc/img/mobile-dashboard.png
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 21 KiB  | 
							
								
								
									
										10
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								package.json
									
									
									
									
									
								
							@ -1,7 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "garage-webui",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "version": "1.0.1",
 | 
			
		||||
  "version": "1.1.0",
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "dev:client": "vite",
 | 
			
		||||
@ -15,7 +15,9 @@
 | 
			
		||||
    "@hookform/resolvers": "^3.9.0",
 | 
			
		||||
    "@tanstack/react-query": "^5.51.23",
 | 
			
		||||
    "clsx": "^2.1.1",
 | 
			
		||||
    "dayjs": "^1.11.12",
 | 
			
		||||
    "lucide-react": "^0.427.0",
 | 
			
		||||
    "mime": "^4.0.4",
 | 
			
		||||
    "react": "^18.3.1",
 | 
			
		||||
    "react-daisyui": "^5.0.3",
 | 
			
		||||
    "react-dom": "^18.3.1",
 | 
			
		||||
@ -45,5 +47,11 @@
 | 
			
		||||
    "typescript": "^5.5.3",
 | 
			
		||||
    "typescript-eslint": "^8.0.0",
 | 
			
		||||
    "vite": "^5.4.0"
 | 
			
		||||
  },
 | 
			
		||||
  "pnpm": {
 | 
			
		||||
    "onlyBuiltDependencies": [
 | 
			
		||||
      "@swc/core",
 | 
			
		||||
      "esbuild"
 | 
			
		||||
    ]
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										18
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										18
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							@ -17,9 +17,15 @@ importers:
 | 
			
		||||
      clsx:
 | 
			
		||||
        specifier: ^2.1.1
 | 
			
		||||
        version: 2.1.1
 | 
			
		||||
      dayjs:
 | 
			
		||||
        specifier: ^1.11.12
 | 
			
		||||
        version: 1.11.12
 | 
			
		||||
      lucide-react:
 | 
			
		||||
        specifier: ^0.427.0
 | 
			
		||||
        version: 0.427.0(react@18.3.1)
 | 
			
		||||
      mime:
 | 
			
		||||
        specifier: ^4.0.4
 | 
			
		||||
        version: 4.0.4
 | 
			
		||||
      react:
 | 
			
		||||
        specifier: ^18.3.1
 | 
			
		||||
        version: 18.3.1
 | 
			
		||||
@ -842,6 +848,9 @@ packages:
 | 
			
		||||
    resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
 | 
			
		||||
    engines: {node: '>=0.11'}
 | 
			
		||||
 | 
			
		||||
  dayjs@1.11.12:
 | 
			
		||||
    resolution: {integrity: sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==}
 | 
			
		||||
 | 
			
		||||
  debug@4.3.6:
 | 
			
		||||
    resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==}
 | 
			
		||||
    engines: {node: '>=6.0'}
 | 
			
		||||
@ -1186,6 +1195,11 @@ packages:
 | 
			
		||||
    resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==}
 | 
			
		||||
    engines: {node: '>=8.6'}
 | 
			
		||||
 | 
			
		||||
  mime@4.0.4:
 | 
			
		||||
    resolution: {integrity: sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ==}
 | 
			
		||||
    engines: {node: '>=16'}
 | 
			
		||||
    hasBin: true
 | 
			
		||||
 | 
			
		||||
  minimatch@3.1.2:
 | 
			
		||||
    resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
 | 
			
		||||
 | 
			
		||||
@ -2395,6 +2409,8 @@ snapshots:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@babel/runtime': 7.25.0
 | 
			
		||||
 | 
			
		||||
  dayjs@1.11.12: {}
 | 
			
		||||
 | 
			
		||||
  debug@4.3.6:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      ms: 2.1.2
 | 
			
		||||
@ -2735,6 +2751,8 @@ snapshots:
 | 
			
		||||
      braces: 3.0.3
 | 
			
		||||
      picomatch: 2.3.1
 | 
			
		||||
 | 
			
		||||
  mime@4.0.4: {}
 | 
			
		||||
 | 
			
		||||
  minimatch@3.1.2:
 | 
			
		||||
    dependencies:
 | 
			
		||||
      brace-expansion: 1.1.11
 | 
			
		||||
 | 
			
		||||
@ -1,48 +1,65 @@
 | 
			
		||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
 | 
			
		||||
import { lazy } from "react";
 | 
			
		||||
import { lazy, Suspense } from "react";
 | 
			
		||||
import AuthLayout from "@/components/layouts/auth-layout";
 | 
			
		||||
import MainLayout from "@/components/layouts/main-layout";
 | 
			
		||||
import { BASE_PATH } from "@/lib/consts";
 | 
			
		||||
 | 
			
		||||
const LoginPage = lazy(() => import("@/pages/auth/login"));
 | 
			
		||||
const ClusterPage = lazy(() => import("@/pages/cluster/page"));
 | 
			
		||||
const HomePage = lazy(() => import("@/pages/home/page"));
 | 
			
		||||
const BucketsPage = lazy(() => import("@/pages/buckets/page"));
 | 
			
		||||
const ManageBucketPage = lazy(() => import("@/pages/buckets/manage/page"));
 | 
			
		||||
const KeysPage = lazy(() => import("@/pages/keys/page"));
 | 
			
		||||
 | 
			
		||||
const router = createBrowserRouter([
 | 
			
		||||
const router = createBrowserRouter(
 | 
			
		||||
  [
 | 
			
		||||
    {
 | 
			
		||||
      path: "/auth",
 | 
			
		||||
      Component: AuthLayout,
 | 
			
		||||
      children: [
 | 
			
		||||
        {
 | 
			
		||||
          path: "login",
 | 
			
		||||
          Component: LoginPage,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      path: "/",
 | 
			
		||||
      Component: MainLayout,
 | 
			
		||||
      children: [
 | 
			
		||||
        {
 | 
			
		||||
          index: true,
 | 
			
		||||
          Component: HomePage,
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          path: "cluster",
 | 
			
		||||
          Component: ClusterPage,
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          path: "buckets",
 | 
			
		||||
          children: [
 | 
			
		||||
            { index: true, Component: BucketsPage },
 | 
			
		||||
            { path: ":id", Component: ManageBucketPage },
 | 
			
		||||
          ],
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          path: "keys",
 | 
			
		||||
          Component: KeysPage,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  {
 | 
			
		||||
    path: "/auth",
 | 
			
		||||
    Component: AuthLayout,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: "/",
 | 
			
		||||
    Component: MainLayout,
 | 
			
		||||
    children: [
 | 
			
		||||
      {
 | 
			
		||||
        index: true,
 | 
			
		||||
        Component: HomePage,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        path: "cluster",
 | 
			
		||||
        Component: ClusterPage,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        path: "buckets",
 | 
			
		||||
        children: [
 | 
			
		||||
          { index: true, Component: BucketsPage },
 | 
			
		||||
          { path: ":id", Component: ManageBucketPage },
 | 
			
		||||
        ],
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        path: "keys",
 | 
			
		||||
        Component: KeysPage,
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
    basename: BASE_PATH,
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const Router = () => {
 | 
			
		||||
  return <RouterProvider router={router} />;
 | 
			
		||||
  return (
 | 
			
		||||
    <Suspense>
 | 
			
		||||
      <RouterProvider router={router} />
 | 
			
		||||
    </Suspense>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default Router;
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,7 @@ import {
 | 
			
		||||
  HardDrive,
 | 
			
		||||
  KeySquare,
 | 
			
		||||
  LayoutDashboard,
 | 
			
		||||
  LogOut,
 | 
			
		||||
  Palette,
 | 
			
		||||
} from "lucide-react";
 | 
			
		||||
import { Dropdown, Menu } from "react-daisyui";
 | 
			
		||||
@ -12,6 +13,11 @@ import Button from "../ui/button";
 | 
			
		||||
import { themes } from "@/app/themes";
 | 
			
		||||
import appStore from "@/stores/app-store";
 | 
			
		||||
import garageLogo from "@/assets/garage-logo.svg";
 | 
			
		||||
import { useMutation } from "@tanstack/react-query";
 | 
			
		||||
import api from "@/lib/api";
 | 
			
		||||
import * as utils from "@/lib/utils";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import { useAuth } from "@/hooks/useAuth";
 | 
			
		||||
 | 
			
		||||
const pages = [
 | 
			
		||||
  { icon: LayoutDashboard, title: "Dashboard", path: "/", exact: true },
 | 
			
		||||
@ -22,6 +28,7 @@ const pages = [
 | 
			
		||||
 | 
			
		||||
const Sidebar = () => {
 | 
			
		||||
  const { pathname } = useLocation();
 | 
			
		||||
  const auth = useAuth();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <aside className="bg-base-100 border-r border-base-300/30 w-[80%] md:w-[250px] flex flex-col items-stretch overflow-hidden h-full">
 | 
			
		||||
@ -57,23 +64,48 @@ const Sidebar = () => {
 | 
			
		||||
        })}
 | 
			
		||||
      </Menu>
 | 
			
		||||
 | 
			
		||||
      <Dropdown className="my-2 mx-4" vertical="top">
 | 
			
		||||
        <Dropdown.Toggle button={false}>
 | 
			
		||||
          <Button icon={Palette} color="ghost">
 | 
			
		||||
            Theme
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Dropdown.Toggle>
 | 
			
		||||
      <div className="py-2 px-4 flex items-center gap-2">
 | 
			
		||||
        <Dropdown vertical="top">
 | 
			
		||||
          <Dropdown.Toggle button={false}>
 | 
			
		||||
            <Button icon={Palette} color="ghost">
 | 
			
		||||
              {!auth.isEnabled ? "Theme" : null}
 | 
			
		||||
            </Button>
 | 
			
		||||
          </Dropdown.Toggle>
 | 
			
		||||
 | 
			
		||||
        <Dropdown.Menu className="max-h-[200px] overflow-y-auto">
 | 
			
		||||
          {themes.map((theme) => (
 | 
			
		||||
            <Dropdown.Item key={theme} onClick={() => appStore.setTheme(theme)}>
 | 
			
		||||
              {ucfirst(theme)}
 | 
			
		||||
            </Dropdown.Item>
 | 
			
		||||
          ))}
 | 
			
		||||
        </Dropdown.Menu>
 | 
			
		||||
      </Dropdown>
 | 
			
		||||
          <Dropdown.Menu className="max-h-[500px] overflow-y-auto">
 | 
			
		||||
            {themes.map((theme) => (
 | 
			
		||||
              <Dropdown.Item
 | 
			
		||||
                key={theme}
 | 
			
		||||
                onClick={() => appStore.setTheme(theme)}
 | 
			
		||||
              >
 | 
			
		||||
                {ucfirst(theme)}
 | 
			
		||||
              </Dropdown.Item>
 | 
			
		||||
            ))}
 | 
			
		||||
          </Dropdown.Menu>
 | 
			
		||||
        </Dropdown>
 | 
			
		||||
 | 
			
		||||
        {auth.isEnabled ? <LogoutButton /> : null}
 | 
			
		||||
      </div>
 | 
			
		||||
    </aside>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const LogoutButton = () => {
 | 
			
		||||
  const logout = useMutation({
 | 
			
		||||
    mutationFn: () => api.post("/auth/logout"),
 | 
			
		||||
    onSuccess: () => {
 | 
			
		||||
      window.location.href = utils.url("/auth/login");
 | 
			
		||||
    },
 | 
			
		||||
    onError: (err) => {
 | 
			
		||||
      toast.error(err?.message || "Unknown error");
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Button className="flex-1" icon={LogOut} onClick={() => logout.mutate()}>
 | 
			
		||||
      Logout
 | 
			
		||||
    </Button>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default Sidebar;
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,7 @@ export type Tab = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  title?: string;
 | 
			
		||||
  icon?: LucideIcon;
 | 
			
		||||
  Component?: () => JSX.Element;
 | 
			
		||||
  Component?: () => JSX.Element | null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
@ -36,13 +36,16 @@ const TabView = ({
 | 
			
		||||
    <>
 | 
			
		||||
      <Tabs
 | 
			
		||||
        variant="boxed"
 | 
			
		||||
        className={cn("w-auto inline-flex flex-row items-stretch", className)}
 | 
			
		||||
        className={cn(
 | 
			
		||||
          "w-auto inline-flex flex-row items-stretch overflow-x-auto",
 | 
			
		||||
          className
 | 
			
		||||
        )}
 | 
			
		||||
      >
 | 
			
		||||
        {tabs.map(({ icon: Icon, ...tab }) => (
 | 
			
		||||
          <Tabs.Tab
 | 
			
		||||
            key={tab.name}
 | 
			
		||||
            active={curTab === tab.name}
 | 
			
		||||
            className="flex flex-row items-center gap-x-2 h-auto"
 | 
			
		||||
            className="flex flex-row items-center gap-x-2 h-auto shrink-0"
 | 
			
		||||
            onClick={() => {
 | 
			
		||||
              setSearchParams((params) => {
 | 
			
		||||
                params.set(name, tab.name);
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,22 @@
 | 
			
		||||
import { useAuth } from "@/hooks/useAuth";
 | 
			
		||||
import { Navigate, Outlet } from "react-router-dom";
 | 
			
		||||
 | 
			
		||||
const AuthLayout = () => {
 | 
			
		||||
  return <div>AuthLayout</div>;
 | 
			
		||||
  const auth = useAuth();
 | 
			
		||||
 | 
			
		||||
  if (auth.isLoading) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (auth.isAuthenticated) {
 | 
			
		||||
    return <Navigate to="/" replace />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="min-h-svh flex items-center justify-center">
 | 
			
		||||
      <Outlet />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default AuthLayout;
 | 
			
		||||
 | 
			
		||||
@ -1,15 +1,17 @@
 | 
			
		||||
import { PageContext } from "@/context/page-context";
 | 
			
		||||
import { Suspense, useContext, useEffect } from "react";
 | 
			
		||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
 | 
			
		||||
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
 | 
			
		||||
import Sidebar from "../containers/sidebar";
 | 
			
		||||
import { ArrowLeft, MenuIcon } from "lucide-react";
 | 
			
		||||
import Button from "../ui/button";
 | 
			
		||||
import { useDisclosure } from "@/hooks/useDisclosure";
 | 
			
		||||
import { Drawer } from "react-daisyui";
 | 
			
		||||
import { useAuth } from "@/hooks/useAuth";
 | 
			
		||||
 | 
			
		||||
const MainLayout = () => {
 | 
			
		||||
  const sidebar = useDisclosure();
 | 
			
		||||
  const { pathname } = useLocation();
 | 
			
		||||
  const auth = useAuth();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (sidebar.isOpen) {
 | 
			
		||||
@ -17,11 +19,19 @@ const MainLayout = () => {
 | 
			
		||||
    }
 | 
			
		||||
  }, [pathname]);
 | 
			
		||||
 | 
			
		||||
  if (auth.isLoading) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!auth.isAuthenticated) {
 | 
			
		||||
    return <Navigate to="/auth/login" />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Drawer
 | 
			
		||||
      open={sidebar.isOpen}
 | 
			
		||||
      onClickOverlay={sidebar.onClose}
 | 
			
		||||
      className="md:drawer-open h-screen"
 | 
			
		||||
      className="md:drawer-open h-screen max-h-dvh"
 | 
			
		||||
      side={<Sidebar />}
 | 
			
		||||
      contentClassName="flex flex-col overflow-hidden"
 | 
			
		||||
    >
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,7 @@ import { Button as BaseButton } from "react-daisyui";
 | 
			
		||||
import { Link } from "react-router-dom";
 | 
			
		||||
 | 
			
		||||
type ButtonProps = ComponentPropsWithoutRef<typeof BaseButton> & {
 | 
			
		||||
  type?: HTMLButtonElement["type"];
 | 
			
		||||
  href?: string;
 | 
			
		||||
  target?: "_blank" | "_self" | "_parent" | "_top";
 | 
			
		||||
  icon?: LucideIcon;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										49
									
								
								src/components/ui/goto-top-btn.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/components/ui/goto-top-btn.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,49 @@
 | 
			
		||||
import { useEffect, useRef, useState } from "react";
 | 
			
		||||
import Button from "./button";
 | 
			
		||||
import { ArrowUp } from "lucide-react";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const GotoTopButton = () => {
 | 
			
		||||
  const mainElRef = useRef<HTMLElement | null>(null);
 | 
			
		||||
  const [show, setShow] = useState(false);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const mainEl = document.querySelector("main");
 | 
			
		||||
    if (!mainEl) return;
 | 
			
		||||
 | 
			
		||||
    mainElRef.current = mainEl;
 | 
			
		||||
 | 
			
		||||
    const onScroll = () => {
 | 
			
		||||
      if (mainEl.scrollTop > 300) {
 | 
			
		||||
        setShow(true);
 | 
			
		||||
      } else {
 | 
			
		||||
        setShow(false);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    mainEl.addEventListener("scroll", onScroll);
 | 
			
		||||
    return () => mainEl.removeEventListener("scroll", onScroll);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const onClick = () => {
 | 
			
		||||
    const mainEl = mainElRef.current;
 | 
			
		||||
    if (!mainEl) return;
 | 
			
		||||
 | 
			
		||||
    mainEl.scrollTo({ top: 0, behavior: "smooth" });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Button
 | 
			
		||||
      icon={ArrowUp}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "fixed bottom-4 right-4 invisible opacity-0 transition-opacity",
 | 
			
		||||
        show && "visible opacity-100"
 | 
			
		||||
      )}
 | 
			
		||||
      onClick={onClick}
 | 
			
		||||
    >
 | 
			
		||||
      Top
 | 
			
		||||
    </Button>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default GotoTopButton;
 | 
			
		||||
@ -46,6 +46,7 @@ export const ToggleField = <T extends FieldValues>({
 | 
			
		||||
          {...props}
 | 
			
		||||
          {...field}
 | 
			
		||||
          className={inputClassName}
 | 
			
		||||
          color={field.value ? "primary" : undefined}
 | 
			
		||||
          checked={field.value || false}
 | 
			
		||||
          onChange={(e) => field.onChange(e.target.checked)}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										7
									
								
								src/global.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/global.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,7 @@
 | 
			
		||||
export {};
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
  interface Window {
 | 
			
		||||
    __BASE_PATH?: string;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								src/hooks/useAuth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/hooks/useAuth.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
import api from "@/lib/api";
 | 
			
		||||
import { useQuery } from "@tanstack/react-query";
 | 
			
		||||
 | 
			
		||||
type AuthResponse = {
 | 
			
		||||
  enabled: boolean;
 | 
			
		||||
  authenticated: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useAuth = () => {
 | 
			
		||||
  const { data, isLoading } = useQuery({
 | 
			
		||||
    queryKey: ["auth"],
 | 
			
		||||
    queryFn: () => api.get<AuthResponse>("/auth/status"),
 | 
			
		||||
    retry: false,
 | 
			
		||||
  });
 | 
			
		||||
  return {
 | 
			
		||||
    isLoading,
 | 
			
		||||
    isEnabled: data?.enabled,
 | 
			
		||||
    isAuthenticated: data?.authenticated,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
@ -1,13 +1,28 @@
 | 
			
		||||
import * as utils from "@/lib/utils";
 | 
			
		||||
import { BASE_PATH } from "./consts";
 | 
			
		||||
 | 
			
		||||
type FetchOptions = Omit<RequestInit, "headers" | "body"> & {
 | 
			
		||||
  params?: Record<string, any>;
 | 
			
		||||
  headers?: Record<string, string>;
 | 
			
		||||
  body?: any;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const API_URL = BASE_PATH + "/api";
 | 
			
		||||
 | 
			
		||||
export class APIError extends Error {
 | 
			
		||||
  status!: number;
 | 
			
		||||
 | 
			
		||||
  constructor(message: string, status: number = 400) {
 | 
			
		||||
    super(message);
 | 
			
		||||
    this.name = "APIError";
 | 
			
		||||
    this.status = status;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const api = {
 | 
			
		||||
  async fetch<T = any>(url: string, options?: Partial<FetchOptions>) {
 | 
			
		||||
    const headers: Record<string, string> = {};
 | 
			
		||||
    const _url = new URL("/api" + url, window.location.origin);
 | 
			
		||||
    const _url = new URL(API_URL + url, window.location.origin);
 | 
			
		||||
 | 
			
		||||
    if (options?.params) {
 | 
			
		||||
      Object.entries(options.params).forEach(([key, value]) => {
 | 
			
		||||
@ -25,26 +40,30 @@ const api = {
 | 
			
		||||
 | 
			
		||||
    const res = await fetch(_url, {
 | 
			
		||||
      ...options,
 | 
			
		||||
      credentials: "include",
 | 
			
		||||
      headers: { ...headers, ...(options?.headers || {}) },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!res.ok) {
 | 
			
		||||
      const json = await res.json().catch(() => {});
 | 
			
		||||
      const message = json?.message || res.statusText;
 | 
			
		||||
      throw new Error(message);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const isJson = res.headers
 | 
			
		||||
      .get("Content-Type")
 | 
			
		||||
      ?.includes("application/json");
 | 
			
		||||
    const data = isJson ? await res.json() : await res.text();
 | 
			
		||||
 | 
			
		||||
    if (isJson) {
 | 
			
		||||
      const json = (await res.json()) as T;
 | 
			
		||||
      return json;
 | 
			
		||||
    if (res.status === 401 && !url.startsWith("/auth")) {
 | 
			
		||||
      window.location.href = utils.url("/auth/login");
 | 
			
		||||
      throw new APIError("unauthorized", res.status);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const text = await res.text();
 | 
			
		||||
    return text as unknown as T;
 | 
			
		||||
    if (!res.ok) {
 | 
			
		||||
      const message = isJson
 | 
			
		||||
        ? data?.message
 | 
			
		||||
        : typeof data === "string"
 | 
			
		||||
        ? data
 | 
			
		||||
        : res.statusText;
 | 
			
		||||
      throw new APIError(message, res.status);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return data as unknown as T;
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  async get<T = any>(url: string, options?: Partial<FetchOptions>) {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										6
									
								
								src/lib/consts.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/lib/consts.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,6 @@
 | 
			
		||||
// consts.ts
 | 
			
		||||
 | 
			
		||||
export const BASE_PATH =
 | 
			
		||||
  (import.meta.env.PROD ? window.__BASE_PATH : null) ||
 | 
			
		||||
  import.meta.env.VITE_BASE_PATH ||
 | 
			
		||||
  "";
 | 
			
		||||
@ -1,3 +1,4 @@
 | 
			
		||||
import { useEffect, useRef } from "react";
 | 
			
		||||
import { createStore, useStore } from "zustand";
 | 
			
		||||
 | 
			
		||||
export const createDisclosure = <T = any>() => {
 | 
			
		||||
@ -6,9 +7,28 @@ export const createDisclosure = <T = any>() => {
 | 
			
		||||
    isOpen: false,
 | 
			
		||||
  }));
 | 
			
		||||
 | 
			
		||||
  const useDisclosure = () => {
 | 
			
		||||
    const dialogRef = useRef<HTMLDialogElement>(null);
 | 
			
		||||
    const data = useStore(store);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      const dlg = dialogRef.current;
 | 
			
		||||
      if (!dlg || !data.isOpen) return;
 | 
			
		||||
 | 
			
		||||
      const onDialogClose = () => {
 | 
			
		||||
        store.setState({ isOpen: false });
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      dlg.addEventListener("close", onDialogClose);
 | 
			
		||||
      return () => dlg.removeEventListener("close", onDialogClose);
 | 
			
		||||
    }, [dialogRef, data.isOpen]);
 | 
			
		||||
 | 
			
		||||
    return { ...data, dialogRef } as const;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    store,
 | 
			
		||||
    use: () => useStore(store),
 | 
			
		||||
    use: useDisclosure,
 | 
			
		||||
    open: (data?: T | null) => store.setState({ isOpen: true, data }),
 | 
			
		||||
    close: () => store.setState({ isOpen: false }),
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,12 @@
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import { twMerge } from "tailwind-merge";
 | 
			
		||||
import dayjsRelativeTime from "dayjs/plugin/relativeTime";
 | 
			
		||||
import dayjs from "dayjs";
 | 
			
		||||
import { BASE_PATH } from "./consts";
 | 
			
		||||
 | 
			
		||||
dayjs.extend(dayjsRelativeTime);
 | 
			
		||||
export { dayjs };
 | 
			
		||||
 | 
			
		||||
export const cn = (...args: any[]) => {
 | 
			
		||||
  return twMerge(clsx(...args));
 | 
			
		||||
@ -48,3 +54,7 @@ export const copyToClipboard = async (text: string) => {
 | 
			
		||||
    textArea?.remove();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const url = (...paths: unknown[]) => {
 | 
			
		||||
  return BASE_PATH + paths.join("/");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										21
									
								
								src/pages/auth/hooks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/pages/auth/hooks.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,21 @@
 | 
			
		||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
import { loginSchema } from "./schema";
 | 
			
		||||
import api from "@/lib/api";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
 | 
			
		||||
export const useLogin = () => {
 | 
			
		||||
  const queryClient = useQueryClient();
 | 
			
		||||
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: async (body: z.infer<typeof loginSchema>) => {
 | 
			
		||||
      return api.post("/auth/login", { body });
 | 
			
		||||
    },
 | 
			
		||||
    onSuccess: () => {
 | 
			
		||||
      queryClient.invalidateQueries({ queryKey: ["auth"] });
 | 
			
		||||
    },
 | 
			
		||||
    onError: (err) => {
 | 
			
		||||
      toast.error(err?.message || "Unknown error");
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										54
									
								
								src/pages/auth/login.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/pages/auth/login.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
			
		||||
import Button from "@/components/ui/button";
 | 
			
		||||
import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		||||
import { Card } from "react-daisyui";
 | 
			
		||||
import { useForm } from "react-hook-form";
 | 
			
		||||
import { loginSchema } from "./schema";
 | 
			
		||||
import { InputField } from "@/components/ui/input";
 | 
			
		||||
import { useLogin } from "./hooks";
 | 
			
		||||
 | 
			
		||||
export default function LoginPage() {
 | 
			
		||||
  const form = useForm({
 | 
			
		||||
    resolver: zodResolver(loginSchema),
 | 
			
		||||
    defaultValues: { username: "", password: "" },
 | 
			
		||||
  });
 | 
			
		||||
  const login = useLogin();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <form onSubmit={form.handleSubmit((v) => login.mutate(v))}>
 | 
			
		||||
      <Card className="w-full max-w-md" bordered>
 | 
			
		||||
        <Card.Body>
 | 
			
		||||
          <Card.Title tag="h2">Login</Card.Title>
 | 
			
		||||
          <p className="text-base-content/60">
 | 
			
		||||
            Enter username and password below to log in to your account
 | 
			
		||||
          </p>
 | 
			
		||||
 | 
			
		||||
          <InputField
 | 
			
		||||
            form={form}
 | 
			
		||||
            name="username"
 | 
			
		||||
            title="Username"
 | 
			
		||||
            placeholder="Enter your username"
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <InputField
 | 
			
		||||
            form={form}
 | 
			
		||||
            name="password"
 | 
			
		||||
            title="Password"
 | 
			
		||||
            type="password"
 | 
			
		||||
            placeholder="Enter your password"
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <Card.Actions className="mt-4">
 | 
			
		||||
            <Button
 | 
			
		||||
              type="submit"
 | 
			
		||||
              color="primary"
 | 
			
		||||
              className="w-full md:w-auto min-w-[100px]"
 | 
			
		||||
              loading={login.isPending}
 | 
			
		||||
            >
 | 
			
		||||
              Login
 | 
			
		||||
            </Button>
 | 
			
		||||
          </Card.Actions>
 | 
			
		||||
        </Card.Body>
 | 
			
		||||
      </Card>
 | 
			
		||||
    </form>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										6
									
								
								src/pages/auth/schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/pages/auth/schema.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,6 @@
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
 | 
			
		||||
export const loginSchema = z.object({
 | 
			
		||||
  username: z.string().min(1, "Username is required"),
 | 
			
		||||
  password: z.string().min(1, "Password is required"),
 | 
			
		||||
});
 | 
			
		||||
@ -4,7 +4,7 @@ import { readableBytes } from "@/lib/utils";
 | 
			
		||||
import Button from "@/components/ui/button";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  data: Bucket;
 | 
			
		||||
  data: Bucket & { aliases: string[] };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const BucketCard = ({ data }: Props) => {
 | 
			
		||||
@ -15,7 +15,7 @@ const BucketCard = ({ data }: Props) => {
 | 
			
		||||
          <ArchiveIcon size={28} className="shrink-0" />
 | 
			
		||||
 | 
			
		||||
          <p className="text-xl font-medium truncate">
 | 
			
		||||
            {data.globalAliases?.join(", ")}
 | 
			
		||||
            {data.aliases?.join(", ")}
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
@ -40,7 +40,9 @@ const BucketCard = ({ data }: Props) => {
 | 
			
		||||
 | 
			
		||||
      <div className="flex flex-row justify-end gap-4">
 | 
			
		||||
        <Button href={`/buckets/${data.id}`}>Manage</Button>
 | 
			
		||||
        {/* <Button color="primary">Browse</Button> */}
 | 
			
		||||
        <Button color="primary" href={`/buckets/${data.id}?tab=browse`}>
 | 
			
		||||
          Browse
 | 
			
		||||
        </Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,7 @@ export const useCreateBucket = (
 | 
			
		||||
  options?: UseMutationOptions<any, Error, CreateBucketSchema>
 | 
			
		||||
) => {
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: (body) => api.post("/v1/bucket", { body }),
 | 
			
		||||
    mutationFn: (body) => api.post("/v2/CreateBucket", { body }),
 | 
			
		||||
    ...options,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										138
									
								
								src/pages/buckets/manage/browse/actions.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								src/pages/buckets/manage/browse/actions.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,138 @@
 | 
			
		||||
import { FolderPlus, UploadIcon } from "lucide-react";
 | 
			
		||||
import Button from "@/components/ui/button";
 | 
			
		||||
import { usePutObject } from "./hooks";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import { handleError } from "@/lib/utils";
 | 
			
		||||
import { useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import { useBucketContext } from "../context";
 | 
			
		||||
import { useDisclosure } from "@/hooks/useDisclosure";
 | 
			
		||||
import { Modal } from "react-daisyui";
 | 
			
		||||
import { createFolderSchema, CreateFolderSchema } from "./schema";
 | 
			
		||||
import { useForm } from "react-hook-form";
 | 
			
		||||
import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		||||
import { InputField } from "@/components/ui/input";
 | 
			
		||||
import { useEffect } from "react";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  prefix: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const Actions = ({ prefix }: Props) => {
 | 
			
		||||
  const { bucketName } = useBucketContext();
 | 
			
		||||
  const queryClient = useQueryClient();
 | 
			
		||||
 | 
			
		||||
  const putObject = usePutObject(bucketName, {
 | 
			
		||||
    onSuccess: () => {
 | 
			
		||||
      toast.success("File uploaded!");
 | 
			
		||||
      queryClient.invalidateQueries({ queryKey: ["browse", bucketName] });
 | 
			
		||||
    },
 | 
			
		||||
    onError: handleError,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const onUploadFile = () => {
 | 
			
		||||
    const input = document.createElement("input");
 | 
			
		||||
    input.type = "file";
 | 
			
		||||
    input.multiple = true;
 | 
			
		||||
 | 
			
		||||
    input.onchange = (e) => {
 | 
			
		||||
      const files = (e.target as HTMLInputElement).files;
 | 
			
		||||
      if (!files?.length) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (files.length > 20) {
 | 
			
		||||
        toast.error("You can only upload up to 20 files at a time");
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      for (const file of files) {
 | 
			
		||||
        const key = prefix + file.name;
 | 
			
		||||
        putObject.mutate({ key, file });
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    input.click();
 | 
			
		||||
    input.remove();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <CreateFolderAction prefix={prefix} />
 | 
			
		||||
      {/* <Button icon={FilePlus} color="ghost" /> */}
 | 
			
		||||
      <Button
 | 
			
		||||
        icon={UploadIcon}
 | 
			
		||||
        color="ghost"
 | 
			
		||||
        title="Upload File"
 | 
			
		||||
        onClick={onUploadFile}
 | 
			
		||||
      />
 | 
			
		||||
      {/* <Button icon={EllipsisVertical} color="ghost" /> */}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type CreateFolderActionProps = {
 | 
			
		||||
  prefix: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const CreateFolderAction = ({ prefix }: CreateFolderActionProps) => {
 | 
			
		||||
  const { isOpen, onOpen, onClose } = useDisclosure();
 | 
			
		||||
  const { bucketName } = useBucketContext();
 | 
			
		||||
  const queryClient = useQueryClient();
 | 
			
		||||
 | 
			
		||||
  const form = useForm<CreateFolderSchema>({
 | 
			
		||||
    resolver: zodResolver(createFolderSchema),
 | 
			
		||||
    defaultValues: { name: "" },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (isOpen) form.setFocus("name");
 | 
			
		||||
  }, [isOpen]);
 | 
			
		||||
 | 
			
		||||
  const createFolder = usePutObject(bucketName, {
 | 
			
		||||
    onSuccess: () => {
 | 
			
		||||
      toast.success("Folder created!");
 | 
			
		||||
      queryClient.invalidateQueries({ queryKey: ["browse", bucketName] });
 | 
			
		||||
      onClose();
 | 
			
		||||
      form.reset();
 | 
			
		||||
    },
 | 
			
		||||
    onError: handleError,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const onSubmit = form.handleSubmit((values) => {
 | 
			
		||||
    createFolder.mutate({ key: `${prefix}${values.name}/`, file: null });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Button
 | 
			
		||||
        icon={FolderPlus}
 | 
			
		||||
        color="ghost"
 | 
			
		||||
        onClick={onOpen}
 | 
			
		||||
        title="Create Folder"
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <Modal open={isOpen}>
 | 
			
		||||
        <Modal.Header>Create Folder</Modal.Header>
 | 
			
		||||
 | 
			
		||||
        <Modal.Body>
 | 
			
		||||
          <form onSubmit={onSubmit}>
 | 
			
		||||
            <InputField form={form} name="name" title="Name" />
 | 
			
		||||
          </form>
 | 
			
		||||
        </Modal.Body>
 | 
			
		||||
 | 
			
		||||
        <Modal.Actions>
 | 
			
		||||
          <Button onClick={onClose}>Cancel</Button>
 | 
			
		||||
          <Button
 | 
			
		||||
            color="primary"
 | 
			
		||||
            onClick={onSubmit}
 | 
			
		||||
            disabled={createFolder.isPending}
 | 
			
		||||
          >
 | 
			
		||||
            Submit
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Modal.Actions>
 | 
			
		||||
      </Modal>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default Actions;
 | 
			
		||||
							
								
								
									
										73
									
								
								src/pages/buckets/manage/browse/browse-tab.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/pages/buckets/manage/browse/browse-tab.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,73 @@
 | 
			
		||||
import { useSearchParams } from "react-router-dom";
 | 
			
		||||
import { Card } from "react-daisyui";
 | 
			
		||||
 | 
			
		||||
import ObjectList from "./object-list";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import ObjectListNavigator from "./object-list-navigator";
 | 
			
		||||
import Actions from "./actions";
 | 
			
		||||
import { useBucketContext } from "../context";
 | 
			
		||||
import ShareDialog from "./share-dialog";
 | 
			
		||||
 | 
			
		||||
const getInitialPrefixes = (searchParams: URLSearchParams) => {
 | 
			
		||||
  const prefix = searchParams.get("prefix");
 | 
			
		||||
  if (prefix) {
 | 
			
		||||
    const paths = prefix.split("/").filter((p) => p);
 | 
			
		||||
    return paths.map((_, i) => paths.slice(0, i + 1).join("/") + "/");
 | 
			
		||||
  }
 | 
			
		||||
  return [];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const BrowseTab = () => {
 | 
			
		||||
  const { bucket } = useBucketContext();
 | 
			
		||||
  const [searchParams, setSearchParams] = useSearchParams();
 | 
			
		||||
  const [prefixHistory, setPrefixHistory] = useState<string[]>(
 | 
			
		||||
    getInitialPrefixes(searchParams)
 | 
			
		||||
  );
 | 
			
		||||
  const [curPrefix, setCurPrefix] = useState(prefixHistory.length - 1);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const prefix = prefixHistory[curPrefix] || "";
 | 
			
		||||
    const newParams = new URLSearchParams(searchParams);
 | 
			
		||||
    newParams.set("prefix", prefix);
 | 
			
		||||
    setSearchParams(newParams);
 | 
			
		||||
  }, [curPrefix]);
 | 
			
		||||
 | 
			
		||||
  const gotoPrefix = (prefix: string) => {
 | 
			
		||||
    const history = prefixHistory.slice(0, curPrefix + 1);
 | 
			
		||||
    setPrefixHistory([...history, prefix]);
 | 
			
		||||
    setCurPrefix(history.length);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (!bucket.keys.find((k) => k.permissions.read && k.permissions.write)) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="p-4 min-h-[200px] flex flex-col items-center justify-center">
 | 
			
		||||
        <p className="text-center max-w-sm">
 | 
			
		||||
          You need to add a key with read & write access to your bucket to be
 | 
			
		||||
          able to browse it.
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <Card className="pb-2">
 | 
			
		||||
        <ObjectListNavigator
 | 
			
		||||
          curPrefix={curPrefix}
 | 
			
		||||
          setCurPrefix={setCurPrefix}
 | 
			
		||||
          prefixHistory={prefixHistory}
 | 
			
		||||
          actions={<Actions prefix={prefixHistory[curPrefix] || ""} />}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <ObjectList
 | 
			
		||||
          prefix={prefixHistory[curPrefix] || ""}
 | 
			
		||||
          onPrefixChange={gotoPrefix}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <ShareDialog />
 | 
			
		||||
      </Card>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default BrowseTab;
 | 
			
		||||
							
								
								
									
										52
									
								
								src/pages/buckets/manage/browse/hooks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/pages/buckets/manage/browse/hooks.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,52 @@
 | 
			
		||||
import api from "@/lib/api";
 | 
			
		||||
import {
 | 
			
		||||
  useMutation,
 | 
			
		||||
  UseMutationOptions,
 | 
			
		||||
  useQuery,
 | 
			
		||||
} from "@tanstack/react-query";
 | 
			
		||||
import {
 | 
			
		||||
  GetObjectsResult,
 | 
			
		||||
  PutObjectPayload,
 | 
			
		||||
  UseBrowserObjectOptions,
 | 
			
		||||
} from "./types";
 | 
			
		||||
 | 
			
		||||
export const useBrowseObjects = (
 | 
			
		||||
  bucket: string,
 | 
			
		||||
  options?: UseBrowserObjectOptions
 | 
			
		||||
) => {
 | 
			
		||||
  return useQuery({
 | 
			
		||||
    queryKey: ["browse", bucket, options],
 | 
			
		||||
    queryFn: () =>
 | 
			
		||||
      api.get<GetObjectsResult>(`/browse/${bucket}`, { params: options }),
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const usePutObject = (
 | 
			
		||||
  bucket: string,
 | 
			
		||||
  options?: UseMutationOptions<any, Error, PutObjectPayload>
 | 
			
		||||
) => {
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: async (body) => {
 | 
			
		||||
      const formData = new FormData();
 | 
			
		||||
      if (body.file) {
 | 
			
		||||
        formData.append("file", body.file);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return api.put(`/browse/${bucket}/${body.key}`, { body: formData });
 | 
			
		||||
    },
 | 
			
		||||
    ...options,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useDeleteObject = (
 | 
			
		||||
  bucket: string,
 | 
			
		||||
  options?: UseMutationOptions<any, Error, { key: string; recursive?: boolean }>
 | 
			
		||||
) => {
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: (data) =>
 | 
			
		||||
      api.delete(`/browse/${bucket}/${data.key}`, {
 | 
			
		||||
        params: { recursive: data.recursive },
 | 
			
		||||
      }),
 | 
			
		||||
    ...options,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										84
									
								
								src/pages/buckets/manage/browse/object-actions.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/pages/buckets/manage/browse/object-actions.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,84 @@
 | 
			
		||||
import { Dropdown } from "react-daisyui";
 | 
			
		||||
import { Object } from "./types";
 | 
			
		||||
import Button from "@/components/ui/button";
 | 
			
		||||
import { DownloadIcon, EllipsisVertical, Share2, Trash } from "lucide-react";
 | 
			
		||||
import { useDeleteObject } from "./hooks";
 | 
			
		||||
import { useBucketContext } from "../context";
 | 
			
		||||
import { useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import { handleError } from "@/lib/utils";
 | 
			
		||||
import { API_URL } from "@/lib/api";
 | 
			
		||||
import { shareDialog } from "./share-dialog";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  prefix?: string;
 | 
			
		||||
  object: Pick<Object, "objectKey" | "url">;
 | 
			
		||||
  end?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ObjectActions = ({ prefix = "", object, end }: Props) => {
 | 
			
		||||
  const { bucketName } = useBucketContext();
 | 
			
		||||
  const queryClient = useQueryClient();
 | 
			
		||||
  const isDirectory = object.objectKey.endsWith("/");
 | 
			
		||||
 | 
			
		||||
  const deleteObject = useDeleteObject(bucketName, {
 | 
			
		||||
    onSuccess: () => {
 | 
			
		||||
      toast.success("Object deleted!");
 | 
			
		||||
      queryClient.invalidateQueries({ queryKey: ["browse", bucketName] });
 | 
			
		||||
    },
 | 
			
		||||
    onError: handleError,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const onDownload = () => {
 | 
			
		||||
    window.open(API_URL + object.url + "?dl=1", "_blank");
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onDelete = () => {
 | 
			
		||||
    if (
 | 
			
		||||
      window.confirm(
 | 
			
		||||
        `Are you sure you want to delete this ${
 | 
			
		||||
          isDirectory ? "directory and its content" : "object"
 | 
			
		||||
        }?`
 | 
			
		||||
      )
 | 
			
		||||
    ) {
 | 
			
		||||
      deleteObject.mutate({
 | 
			
		||||
        key: prefix + object.objectKey,
 | 
			
		||||
        recursive: isDirectory,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <td className="!p-0 w-auto">
 | 
			
		||||
      <span className="w-full flex flex-row justify-end pr-2">
 | 
			
		||||
        {!isDirectory && (
 | 
			
		||||
          <Button icon={DownloadIcon} color="ghost" onClick={onDownload} />
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <Dropdown end vertical={end ? "top" : "bottom"}>
 | 
			
		||||
          <Dropdown.Toggle button={false}>
 | 
			
		||||
            <Button icon={EllipsisVertical} color="ghost" />
 | 
			
		||||
          </Dropdown.Toggle>
 | 
			
		||||
 | 
			
		||||
          <Dropdown.Menu className="gap-y-1">
 | 
			
		||||
            <Dropdown.Item
 | 
			
		||||
              onClick={() =>
 | 
			
		||||
                shareDialog.open({ key: object.objectKey, prefix })
 | 
			
		||||
              }
 | 
			
		||||
            >
 | 
			
		||||
              <Share2 /> Share
 | 
			
		||||
            </Dropdown.Item>
 | 
			
		||||
            <Dropdown.Item
 | 
			
		||||
              className="text-error bg-error/10"
 | 
			
		||||
              onClick={onDelete}
 | 
			
		||||
            >
 | 
			
		||||
              <Trash /> Delete
 | 
			
		||||
            </Dropdown.Item>
 | 
			
		||||
          </Dropdown.Menu>
 | 
			
		||||
        </Dropdown>
 | 
			
		||||
      </span>
 | 
			
		||||
    </td>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default ObjectActions;
 | 
			
		||||
							
								
								
									
										111
									
								
								src/pages/buckets/manage/browse/object-list-navigator.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								src/pages/buckets/manage/browse/object-list-navigator.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,111 @@
 | 
			
		||||
import Button from "@/components/ui/button";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { ChevronLeft, ChevronRight, Home, LucideIcon } from "lucide-react";
 | 
			
		||||
import { Fragment } from "react/jsx-runtime";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  curPrefix: number;
 | 
			
		||||
  setCurPrefix: React.Dispatch<React.SetStateAction<number>>;
 | 
			
		||||
  prefixHistory: string[];
 | 
			
		||||
  actions?: React.ReactNode;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ObjectListNavigator = ({
 | 
			
		||||
  curPrefix,
 | 
			
		||||
  setCurPrefix,
 | 
			
		||||
  prefixHistory,
 | 
			
		||||
  actions,
 | 
			
		||||
}: Props) => {
 | 
			
		||||
  const onGoBack = () => {
 | 
			
		||||
    if (curPrefix >= 0) setCurPrefix(curPrefix - 1);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onGoForward = () => {
 | 
			
		||||
    if (curPrefix < prefixHistory.length - 1) setCurPrefix(curPrefix + 1);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-row flex-wrap items-center p-2 gap-y-2">
 | 
			
		||||
      <div className="order-1 flex flex-row items-center">
 | 
			
		||||
        <Button
 | 
			
		||||
          icon={ChevronLeft}
 | 
			
		||||
          color="ghost"
 | 
			
		||||
          disabled={curPrefix < 0}
 | 
			
		||||
          onClick={onGoBack}
 | 
			
		||||
          className="col-span-2"
 | 
			
		||||
        />
 | 
			
		||||
        <Button
 | 
			
		||||
          icon={ChevronRight}
 | 
			
		||||
          color="ghost"
 | 
			
		||||
          disabled={curPrefix >= prefixHistory.length - 1}
 | 
			
		||||
          onClick={onGoForward}
 | 
			
		||||
          className="col-span-2"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="order-3 md:order-2 flex flex-row w-full overflow-x-auto items-center bg-base-200 h-10 flex-1 shrink-0 min-w-[80%] md:min-w-0 rounded-lg mx-2 px-2">
 | 
			
		||||
        <HistoryItem
 | 
			
		||||
          icon={Home}
 | 
			
		||||
          isActive={curPrefix === -1}
 | 
			
		||||
          onClick={() => setCurPrefix(-1)}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        {prefixHistory.map((prefix, i) => (
 | 
			
		||||
          <Fragment key={prefix}>
 | 
			
		||||
            <ChevronRight className="shrink-0" size={18} />
 | 
			
		||||
            <HistoryItem
 | 
			
		||||
              title={prefix
 | 
			
		||||
                .substring(0, prefix.lastIndexOf("/"))
 | 
			
		||||
                .split("/")
 | 
			
		||||
                .pop()}
 | 
			
		||||
              isActive={i === curPrefix}
 | 
			
		||||
              onClick={() => setCurPrefix(i)}
 | 
			
		||||
            />
 | 
			
		||||
          </Fragment>
 | 
			
		||||
        ))}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="order-2 flex flex-row items-center flex-1 md:order-3 md:flex-initial justify-end">
 | 
			
		||||
        {actions}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type HistoryItemProps = {
 | 
			
		||||
  icon?: LucideIcon;
 | 
			
		||||
  title?: string;
 | 
			
		||||
  isActive: boolean;
 | 
			
		||||
  onClick: () => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const HistoryItem = ({
 | 
			
		||||
  icon: Icon,
 | 
			
		||||
  title,
 | 
			
		||||
  isActive,
 | 
			
		||||
  onClick,
 | 
			
		||||
}: HistoryItemProps) => {
 | 
			
		||||
  if (!title && !Icon) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <a
 | 
			
		||||
      href="#"
 | 
			
		||||
      onClick={(e) => {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        onClick();
 | 
			
		||||
      }}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "px-2 rounded-md shrink-0 max-w-[150px] truncate",
 | 
			
		||||
        isActive && "bg-neutral",
 | 
			
		||||
        Icon ? "py-1" : null
 | 
			
		||||
      )}
 | 
			
		||||
    >
 | 
			
		||||
      {Icon ? <Icon size={18} /> : null}
 | 
			
		||||
      {title}
 | 
			
		||||
    </a>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default ObjectListNavigator;
 | 
			
		||||
							
								
								
									
										177
									
								
								src/pages/buckets/manage/browse/object-list.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								src/pages/buckets/manage/browse/object-list.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,177 @@
 | 
			
		||||
import { Alert, Loading, Table } from "react-daisyui";
 | 
			
		||||
import { useBrowseObjects } from "./hooks";
 | 
			
		||||
import { dayjs, readableBytes } from "@/lib/utils";
 | 
			
		||||
import mime from "mime/lite";
 | 
			
		||||
import { Object } from "./types";
 | 
			
		||||
import { API_URL } from "@/lib/api";
 | 
			
		||||
import {
 | 
			
		||||
  CircleXIcon,
 | 
			
		||||
  FileArchive,
 | 
			
		||||
  FileIcon,
 | 
			
		||||
  FileType,
 | 
			
		||||
  Folder,
 | 
			
		||||
} from "lucide-react";
 | 
			
		||||
import { useBucketContext } from "../context";
 | 
			
		||||
import ObjectActions from "./object-actions";
 | 
			
		||||
import GotoTopButton from "@/components/ui/goto-top-btn";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  prefix?: string;
 | 
			
		||||
  onPrefixChange?: (prefix: string) => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ObjectList = ({ prefix, onPrefixChange }: Props) => {
 | 
			
		||||
  const { bucketName } = useBucketContext();
 | 
			
		||||
  const { data, error, isLoading } = useBrowseObjects(bucketName, {
 | 
			
		||||
    prefix,
 | 
			
		||||
    limit: 1000,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const onObjectClick = (object: Object) => {
 | 
			
		||||
    window.open(API_URL + object.url + "?view=1", "_blank");
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="overflow-x-auto min-h-[400px]">
 | 
			
		||||
      <Table>
 | 
			
		||||
        <Table.Head>
 | 
			
		||||
          <span>Name</span>
 | 
			
		||||
          <span>Size</span>
 | 
			
		||||
          <span>Last Modified</span>
 | 
			
		||||
        </Table.Head>
 | 
			
		||||
 | 
			
		||||
        <Table.Body>
 | 
			
		||||
          {isLoading ? (
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td colSpan={3}>
 | 
			
		||||
                <div className="h-[320px] flex items-center justify-center">
 | 
			
		||||
                  <Loading />
 | 
			
		||||
                </div>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          ) : error ? (
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td colSpan={3}>
 | 
			
		||||
                <Alert status="error" icon={<CircleXIcon />}>
 | 
			
		||||
                  <span>{error.message}</span>
 | 
			
		||||
                </Alert>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          ) : !data?.prefixes?.length && !data?.objects?.length ? (
 | 
			
		||||
            <tr>
 | 
			
		||||
              <td className="text-center py-16" colSpan={3}>
 | 
			
		||||
                No objects
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          ) : null}
 | 
			
		||||
 | 
			
		||||
          {data?.prefixes.map((prefix) => (
 | 
			
		||||
            <tr
 | 
			
		||||
              key={prefix}
 | 
			
		||||
              className="hover:bg-neutral/60 hover:text-neutral-content group"
 | 
			
		||||
            >
 | 
			
		||||
              <td
 | 
			
		||||
                className="cursor-pointer"
 | 
			
		||||
                role="button"
 | 
			
		||||
                onClick={() => onPrefixChange?.(prefix)}
 | 
			
		||||
              >
 | 
			
		||||
                <span className="flex items-center gap-2 font-normal">
 | 
			
		||||
                  <Folder size={20} className="text-primary" />
 | 
			
		||||
                  {prefix
 | 
			
		||||
                    .substring(0, prefix.lastIndexOf("/"))
 | 
			
		||||
                    .split("/")
 | 
			
		||||
                    .pop()}
 | 
			
		||||
                </span>
 | 
			
		||||
              </td>
 | 
			
		||||
              <td colSpan={2} />
 | 
			
		||||
              <ObjectActions object={{ objectKey: prefix, url: "" }} />
 | 
			
		||||
            </tr>
 | 
			
		||||
          ))}
 | 
			
		||||
 | 
			
		||||
          {data?.objects.map((object, idx) => {
 | 
			
		||||
            const extIdx = object.objectKey.lastIndexOf(".");
 | 
			
		||||
            const filename =
 | 
			
		||||
              extIdx >= 0
 | 
			
		||||
                ? object.objectKey.substring(0, extIdx)
 | 
			
		||||
                : object.objectKey;
 | 
			
		||||
            const ext = extIdx >= 0 ? object.objectKey.substring(extIdx) : null;
 | 
			
		||||
 | 
			
		||||
            return (
 | 
			
		||||
              <tr
 | 
			
		||||
                key={object.objectKey}
 | 
			
		||||
                className="hover:bg-neutral/60 hover:text-neutral-content group"
 | 
			
		||||
              >
 | 
			
		||||
                <td
 | 
			
		||||
                  className="cursor-pointer"
 | 
			
		||||
                  role="button"
 | 
			
		||||
                  onClick={() => onObjectClick(object)}
 | 
			
		||||
                >
 | 
			
		||||
                  <span className="flex items-center font-normal w-full">
 | 
			
		||||
                    <FilePreview ext={ext?.substring(1)} object={object} />
 | 
			
		||||
                    <span className="truncate max-w-[40vw]">{filename}</span>
 | 
			
		||||
                    {ext && <span className="text-base-content/60">{ext}</span>}
 | 
			
		||||
                  </span>
 | 
			
		||||
                </td>
 | 
			
		||||
                <td className="whitespace-nowrap">
 | 
			
		||||
                  {readableBytes(object.size)}
 | 
			
		||||
                </td>
 | 
			
		||||
                <td className="whitespace-nowrap">
 | 
			
		||||
                  {dayjs(object.lastModified).fromNow()}
 | 
			
		||||
                </td>
 | 
			
		||||
                <ObjectActions
 | 
			
		||||
                  prefix={data.prefix}
 | 
			
		||||
                  object={object}
 | 
			
		||||
                  end={
 | 
			
		||||
                    idx >= data.objects.length - 2 && data.objects.length > 5
 | 
			
		||||
                  }
 | 
			
		||||
                />
 | 
			
		||||
              </tr>
 | 
			
		||||
            );
 | 
			
		||||
          })}
 | 
			
		||||
        </Table.Body>
 | 
			
		||||
      </Table>
 | 
			
		||||
 | 
			
		||||
      <GotoTopButton />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type FilePreviewProps = {
 | 
			
		||||
  ext?: string | null;
 | 
			
		||||
  object: Object;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const FilePreview = ({ ext, object }: FilePreviewProps) => {
 | 
			
		||||
  const type = mime.getType(ext || "")?.split("/")[0];
 | 
			
		||||
  let Icon = FileIcon;
 | 
			
		||||
 | 
			
		||||
  if (
 | 
			
		||||
    ["zip", "rar", "7z", "iso", "tar", "gz", "bz2", "xz"].includes(ext || "")
 | 
			
		||||
  ) {
 | 
			
		||||
    Icon = FileArchive;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (type === "image") {
 | 
			
		||||
    const thumbnailSupport = ["jpg", "jpeg", "png", "gif"].includes(ext || "");
 | 
			
		||||
    return (
 | 
			
		||||
      <img
 | 
			
		||||
        src={API_URL + object.url + (thumbnailSupport ? "?thumb=1" : "?view=1")}
 | 
			
		||||
        alt={object.objectKey}
 | 
			
		||||
        className="size-5 object-cover overflow-hidden mr-2"
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (type === "text") {
 | 
			
		||||
    Icon = FileType;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Icon
 | 
			
		||||
      size={20}
 | 
			
		||||
      className="text-base-content/60 group-hover:text-neutral-content/80 mr-2"
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default ObjectList;
 | 
			
		||||
							
								
								
									
										10
									
								
								src/pages/buckets/manage/browse/schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/pages/buckets/manage/browse/schema.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
 | 
			
		||||
export const createFolderSchema = z.object({
 | 
			
		||||
  name: z
 | 
			
		||||
    .string()
 | 
			
		||||
    .min(1, "Folder Name is required")
 | 
			
		||||
    .regex(/^[a-zA-Z0-9_-]+$/, "Folder Name invalid"),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export type CreateFolderSchema = z.infer<typeof createFolderSchema>;
 | 
			
		||||
							
								
								
									
										79
									
								
								src/pages/buckets/manage/browse/share-dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								src/pages/buckets/manage/browse/share-dialog.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,79 @@
 | 
			
		||||
import { createDisclosure } from "@/lib/disclosure";
 | 
			
		||||
import { Alert, Modal } from "react-daisyui";
 | 
			
		||||
import { useBucketContext } from "../context";
 | 
			
		||||
import { useConfig } from "@/hooks/useConfig";
 | 
			
		||||
import { useEffect, useMemo, useState } from "react";
 | 
			
		||||
import Input from "@/components/ui/input";
 | 
			
		||||
import Button from "@/components/ui/button";
 | 
			
		||||
import { Copy, FileWarningIcon } from "lucide-react";
 | 
			
		||||
import { copyToClipboard } from "@/lib/utils";
 | 
			
		||||
import Checkbox from "@/components/ui/checkbox";
 | 
			
		||||
 | 
			
		||||
export const shareDialog = createDisclosure<{ key: string; prefix: string }>();
 | 
			
		||||
 | 
			
		||||
const ShareDialog = () => {
 | 
			
		||||
  const { isOpen, data, dialogRef } = shareDialog.use();
 | 
			
		||||
  const { bucket, bucketName } = useBucketContext();
 | 
			
		||||
  const { data: config } = useConfig();
 | 
			
		||||
  const [domain, setDomain] = useState(bucketName);
 | 
			
		||||
 | 
			
		||||
  const websitePort = config?.s3_web?.bind_addr?.split(":").pop() || "80";
 | 
			
		||||
  const rootDomain = config?.s3_web?.root_domain;
 | 
			
		||||
 | 
			
		||||
  const domains = useMemo(
 | 
			
		||||
    () => [
 | 
			
		||||
      bucketName,
 | 
			
		||||
      bucketName + rootDomain,
 | 
			
		||||
      bucketName + rootDomain + `:${websitePort}`,
 | 
			
		||||
    ],
 | 
			
		||||
    [bucketName, config?.s3_web]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setDomain(bucketName);
 | 
			
		||||
  }, [domains]);
 | 
			
		||||
 | 
			
		||||
  const url = "http://" + domain + "/" + data?.prefix + data?.key;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Modal ref={dialogRef} open={isOpen} backdrop>
 | 
			
		||||
      <Modal.Header className="truncate">Share {data?.key || ""}</Modal.Header>
 | 
			
		||||
      <Modal.Body>
 | 
			
		||||
        {!bucket.websiteAccess && (
 | 
			
		||||
          <Alert className="mb-4 items-start text-sm">
 | 
			
		||||
            <FileWarningIcon className="mt-1" />
 | 
			
		||||
            Sharing is only available for buckets with enabled website access.
 | 
			
		||||
          </Alert>
 | 
			
		||||
        )}
 | 
			
		||||
        <div className="flex flex-row overflow-x-auto pb-2">
 | 
			
		||||
          {domains.map((item) => (
 | 
			
		||||
            <Checkbox
 | 
			
		||||
              key={item}
 | 
			
		||||
              label={item}
 | 
			
		||||
              checked={item === domain}
 | 
			
		||||
              onChange={() => setDomain(item)}
 | 
			
		||||
            />
 | 
			
		||||
          ))}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="relative mt-2">
 | 
			
		||||
          <Input
 | 
			
		||||
            value={url}
 | 
			
		||||
            className="w-full pr-12"
 | 
			
		||||
            onFocus={(e) => e.target.select()}
 | 
			
		||||
          />
 | 
			
		||||
          <Button
 | 
			
		||||
            icon={Copy}
 | 
			
		||||
            onClick={() => copyToClipboard(url)}
 | 
			
		||||
            className="absolute top-0 right-0"
 | 
			
		||||
            color="ghost"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </Modal.Body>
 | 
			
		||||
      <Modal.Actions>
 | 
			
		||||
        <Button onClick={() => shareDialog.close()}>Close</Button>
 | 
			
		||||
      </Modal.Actions>
 | 
			
		||||
    </Modal>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default ShareDialog;
 | 
			
		||||
							
								
								
									
										24
									
								
								src/pages/buckets/manage/browse/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/pages/buckets/manage/browse/types.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
export type UseBrowserObjectOptions = Partial<{
 | 
			
		||||
  prefix: string;
 | 
			
		||||
  limit: number;
 | 
			
		||||
  next: string;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
export type GetObjectsResult = {
 | 
			
		||||
  prefixes: string[];
 | 
			
		||||
  objects: Object[];
 | 
			
		||||
  prefix: string;
 | 
			
		||||
  nextToken: string | null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type Object = {
 | 
			
		||||
  objectKey: string;
 | 
			
		||||
  lastModified: Date;
 | 
			
		||||
  size: number;
 | 
			
		||||
  url: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type PutObjectPayload = {
 | 
			
		||||
  key: string;
 | 
			
		||||
  file: File | null;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										19
									
								
								src/pages/buckets/manage/context.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/pages/buckets/manage/context.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
			
		||||
import { createContext, useContext } from "react";
 | 
			
		||||
import { Bucket } from "../types";
 | 
			
		||||
 | 
			
		||||
export const BucketContext = createContext<{
 | 
			
		||||
  bucket: Bucket;
 | 
			
		||||
  refetch: () => void;
 | 
			
		||||
  bucketName: string;
 | 
			
		||||
} | null>(null);
 | 
			
		||||
 | 
			
		||||
export const useBucketContext = () => {
 | 
			
		||||
  const bucket = useContext(BucketContext);
 | 
			
		||||
  if (!bucket) {
 | 
			
		||||
    throw new Error(
 | 
			
		||||
      "BucketContext must be used within a BucketContextProvider"
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return bucket;
 | 
			
		||||
};
 | 
			
		||||
@ -10,7 +10,7 @@ import { Bucket, Permissions } from "../types";
 | 
			
		||||
export const useBucket = (id?: string | null) => {
 | 
			
		||||
  return useQuery({
 | 
			
		||||
    queryKey: ["bucket", id],
 | 
			
		||||
    queryFn: () => api.get<Bucket>("/v1/bucket", { params: { id } }),
 | 
			
		||||
    queryFn: () => api.get<Bucket>("/v2/GetBucketInfo", { params: { id } }),
 | 
			
		||||
    enabled: !!id,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
@ -18,7 +18,10 @@ export const useBucket = (id?: string | null) => {
 | 
			
		||||
export const useUpdateBucket = (id?: string | null) => {
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: (values: any) => {
 | 
			
		||||
      return api.put<any>("/v1/bucket", { params: { id }, body: values });
 | 
			
		||||
      return api.post<any>("/v2/UpdateBucket", {
 | 
			
		||||
        params: { id },
 | 
			
		||||
        body: values,
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
@ -29,8 +32,8 @@ export const useAddAlias = (
 | 
			
		||||
) => {
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: (alias: string) => {
 | 
			
		||||
      return api.put("/v1/bucket/alias/global", {
 | 
			
		||||
        params: { id: bucketId, alias },
 | 
			
		||||
      return api.post("/v2/AddBucketAlias", {
 | 
			
		||||
        body: { bucketId, globalAlias: alias },
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    ...options,
 | 
			
		||||
@ -43,8 +46,8 @@ export const useRemoveAlias = (
 | 
			
		||||
) => {
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: (alias: string) => {
 | 
			
		||||
      return api.delete("/v1/bucket/alias/global", {
 | 
			
		||||
        params: { id: bucketId, alias },
 | 
			
		||||
      return api.post("/v2/RemoveBucketAlias", {
 | 
			
		||||
        body: { bucketId, globalAlias: alias },
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    ...options,
 | 
			
		||||
@ -62,8 +65,7 @@ export const useAllowKey = (
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: async (payload) => {
 | 
			
		||||
      const promises = payload.map(async (key) => {
 | 
			
		||||
        console.log("test", key);
 | 
			
		||||
        return api.post("/v1/bucket/allow", {
 | 
			
		||||
        return api.post("/v2/AllowBucketKey", {
 | 
			
		||||
          body: {
 | 
			
		||||
            bucketId,
 | 
			
		||||
            accessKeyId: key.keyId,
 | 
			
		||||
@ -88,7 +90,7 @@ export const useDenyKey = (
 | 
			
		||||
) => {
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: (payload) => {
 | 
			
		||||
      return api.post("/v1/bucket/deny", {
 | 
			
		||||
      return api.post("/v2/DenyBucketKey", {
 | 
			
		||||
        body: {
 | 
			
		||||
          bucketId,
 | 
			
		||||
          accessKeyId: payload.keyId,
 | 
			
		||||
@ -104,7 +106,7 @@ export const useRemoveBucket = (
 | 
			
		||||
  options?: MutationOptions<any, Error, string>
 | 
			
		||||
) => {
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: (id) => api.delete("/v1/bucket", { params: { id } }),
 | 
			
		||||
    mutationFn: (id) => api.post("/v2/DeleteBucket", { params: { id } }),
 | 
			
		||||
    ...options,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
import { Modal } from "react-daisyui";
 | 
			
		||||
import { Plus } from "lucide-react";
 | 
			
		||||
import Chips from "@/components/ui/chips";
 | 
			
		||||
import { Bucket } from "../../types";
 | 
			
		||||
import { useDisclosure } from "@/hooks/useDisclosure";
 | 
			
		||||
import { useForm } from "react-hook-form";
 | 
			
		||||
import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		||||
@ -13,12 +12,11 @@ import { handleError } from "@/lib/utils";
 | 
			
		||||
import { InputField } from "@/components/ui/input";
 | 
			
		||||
import { useEffect } from "react";
 | 
			
		||||
import { useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import { useBucketContext } from "../context";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  data?: Bucket;
 | 
			
		||||
};
 | 
			
		||||
const AliasesSection = () => {
 | 
			
		||||
  const { bucket: data } = useBucketContext();
 | 
			
		||||
 | 
			
		||||
const AliasesSection = ({ data }: Props) => {
 | 
			
		||||
  const queryClient = useQueryClient();
 | 
			
		||||
  const removeAlias = useRemoveAlias(data?.id, {
 | 
			
		||||
    onSuccess: () => {
 | 
			
		||||
@ -4,15 +4,13 @@ import { QuotaSchema, quotaSchema } from "../schema";
 | 
			
		||||
import { useEffect } from "react";
 | 
			
		||||
import { useDebounce } from "@/hooks/useDebounce";
 | 
			
		||||
import { useUpdateBucket } from "../hooks";
 | 
			
		||||
import { Bucket } from "../../types";
 | 
			
		||||
import { InputField } from "@/components/ui/input";
 | 
			
		||||
import { ToggleField } from "@/components/ui/toggle";
 | 
			
		||||
import { useBucketContext } from "../context";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  data?: Bucket;
 | 
			
		||||
};
 | 
			
		||||
const QuotaSection = () => {
 | 
			
		||||
  const { bucket: data } = useBucketContext();
 | 
			
		||||
 | 
			
		||||
const QuotaSection = ({ data }: Props) => {
 | 
			
		||||
  const form = useForm<QuotaSchema>({
 | 
			
		||||
    resolver: zodResolver(quotaSchema),
 | 
			
		||||
  });
 | 
			
		||||
@ -23,7 +21,7 @@ const QuotaSection = ({ data }: Props) => {
 | 
			
		||||
  const onChange = useDebounce((values: DeepPartial<QuotaSchema>) => {
 | 
			
		||||
    const { enabled } = values;
 | 
			
		||||
    const maxObjects = Number(values.maxObjects);
 | 
			
		||||
    const maxSize = Math.round(Number(values.maxSize) * 1024 * 1024);
 | 
			
		||||
    const maxSize = Math.round(Number(values.maxSize) * 1024 ** 3);
 | 
			
		||||
 | 
			
		||||
    const data = {
 | 
			
		||||
      maxObjects: enabled && maxObjects > 0 ? maxObjects : null,
 | 
			
		||||
@ -37,9 +35,7 @@ const QuotaSection = ({ data }: Props) => {
 | 
			
		||||
    form.reset({
 | 
			
		||||
      enabled:
 | 
			
		||||
        data?.quotas?.maxSize != null || data?.quotas?.maxObjects != null,
 | 
			
		||||
      maxSize: data?.quotas?.maxSize
 | 
			
		||||
        ? data?.quotas?.maxSize / 1024 / 1024
 | 
			
		||||
        : null,
 | 
			
		||||
      maxSize: data?.quotas?.maxSize ? data?.quotas?.maxSize / 1024 ** 3 : null,
 | 
			
		||||
      maxObjects: data?.quotas?.maxObjects || null,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -1,24 +1,22 @@
 | 
			
		||||
import { Card } from "react-daisyui";
 | 
			
		||||
import { useParams } from "react-router-dom";
 | 
			
		||||
import { useBucket } from "../hooks";
 | 
			
		||||
import { ChartPie, ChartScatter } from "lucide-react";
 | 
			
		||||
import { readableBytes } from "@/lib/utils";
 | 
			
		||||
import WebsiteAccessSection from "./overview-website-access";
 | 
			
		||||
import AliasesSection from "./overview-aliases";
 | 
			
		||||
import QuotaSection from "./overview-quota";
 | 
			
		||||
import { useBucketContext } from "../context";
 | 
			
		||||
 | 
			
		||||
const OverviewTab = () => {
 | 
			
		||||
  const { id } = useParams();
 | 
			
		||||
  const { data } = useBucket(id);
 | 
			
		||||
  const { bucket: data } = useBucketContext();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-8 items-start">
 | 
			
		||||
      <Card className="card-body gap-0 items-start order-2 md:order-1">
 | 
			
		||||
        <Card.Title>Summary</Card.Title>
 | 
			
		||||
 | 
			
		||||
        <AliasesSection data={data} />
 | 
			
		||||
        <WebsiteAccessSection data={data} />
 | 
			
		||||
        <QuotaSection data={data} />
 | 
			
		||||
        <AliasesSection />
 | 
			
		||||
        <WebsiteAccessSection />
 | 
			
		||||
        <QuotaSection />
 | 
			
		||||
      </Card>
 | 
			
		||||
 | 
			
		||||
      <Card className="card-body order-1 md:order-2">
 | 
			
		||||
@ -7,20 +7,16 @@ import { useUpdateBucket } from "../hooks";
 | 
			
		||||
import { useConfig } from "@/hooks/useConfig";
 | 
			
		||||
import { Info, LinkIcon } from "lucide-react";
 | 
			
		||||
import Button from "@/components/ui/button";
 | 
			
		||||
import { Bucket } from "../../types";
 | 
			
		||||
import { InputField } from "@/components/ui/input";
 | 
			
		||||
import { ToggleField } from "@/components/ui/toggle";
 | 
			
		||||
import { useBucketContext } from "../context";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  data?: Bucket;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const WebsiteAccessSection = ({ data }: Props) => {
 | 
			
		||||
const WebsiteAccessSection = () => {
 | 
			
		||||
  const { bucket: data, bucketName } = useBucketContext();
 | 
			
		||||
  const { data: config } = useConfig();
 | 
			
		||||
  const form = useForm<WebsiteConfigSchema>({
 | 
			
		||||
    resolver: zodResolver(websiteConfigSchema),
 | 
			
		||||
  });
 | 
			
		||||
  const bucketName = data?.globalAliases[0] || "";
 | 
			
		||||
  const isEnabled = useWatch({ control: form.control, name: "websiteAccess" });
 | 
			
		||||
 | 
			
		||||
  const websitePort = config?.s3_web?.bind_addr?.split(":").pop() || "80";
 | 
			
		||||
@ -2,10 +2,18 @@ import { useParams } from "react-router-dom";
 | 
			
		||||
import { useBucket } from "./hooks";
 | 
			
		||||
import Page from "@/context/page-context";
 | 
			
		||||
import TabView, { Tab } from "@/components/containers/tab-view";
 | 
			
		||||
import { ChartLine, LockKeyhole } from "lucide-react";
 | 
			
		||||
import OverviewTab from "./components/overview-tab";
 | 
			
		||||
import PermissionsTab from "./components/permissions-tab";
 | 
			
		||||
import {
 | 
			
		||||
  ChartLine,
 | 
			
		||||
  CircleXIcon,
 | 
			
		||||
  FolderSearch,
 | 
			
		||||
  LockKeyhole,
 | 
			
		||||
} from "lucide-react";
 | 
			
		||||
import OverviewTab from "./overview/overview-tab";
 | 
			
		||||
import PermissionsTab from "./permissions/permissions-tab";
 | 
			
		||||
import MenuButton from "./components/menu-button";
 | 
			
		||||
import BrowseTab from "./browse/browse-tab";
 | 
			
		||||
import { BucketContext } from "./context";
 | 
			
		||||
import { Alert, Loading } from "react-daisyui";
 | 
			
		||||
 | 
			
		||||
const tabs: Tab[] = [
 | 
			
		||||
  {
 | 
			
		||||
@ -20,28 +28,50 @@ const tabs: Tab[] = [
 | 
			
		||||
    icon: LockKeyhole,
 | 
			
		||||
    Component: PermissionsTab,
 | 
			
		||||
  },
 | 
			
		||||
  // {
 | 
			
		||||
  //   name: "browse",
 | 
			
		||||
  //   title: "Browse",
 | 
			
		||||
  //   icon: FolderSearch,
 | 
			
		||||
  // },
 | 
			
		||||
  {
 | 
			
		||||
    name: "browse",
 | 
			
		||||
    title: "Browse",
 | 
			
		||||
    icon: FolderSearch,
 | 
			
		||||
    Component: BrowseTab,
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const ManageBucketPage = () => {
 | 
			
		||||
  const { id } = useParams();
 | 
			
		||||
  const { data } = useBucket(id);
 | 
			
		||||
  const { data, error, isLoading, refetch } = useBucket(id);
 | 
			
		||||
 | 
			
		||||
  const name = data?.globalAliases[0];
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="container">
 | 
			
		||||
    <>
 | 
			
		||||
      <Page
 | 
			
		||||
        title={name || "Manage Bucket"}
 | 
			
		||||
        prev="/buckets"
 | 
			
		||||
        actions={<MenuButton />}
 | 
			
		||||
        actions={data ? <MenuButton /> : undefined}
 | 
			
		||||
      />
 | 
			
		||||
      <TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" />
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
      {isLoading && (
 | 
			
		||||
        <div className="h-full flex items-center justify-center">
 | 
			
		||||
          <Loading size="lg" />
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {error != null && (
 | 
			
		||||
        <Alert status="error" icon={<CircleXIcon />}>
 | 
			
		||||
          <span>{error.message}</span>
 | 
			
		||||
        </Alert>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {data && (
 | 
			
		||||
        <div className="container">
 | 
			
		||||
          <BucketContext.Provider
 | 
			
		||||
            value={{ bucket: data, refetch, bucketName: name || "" }}
 | 
			
		||||
          >
 | 
			
		||||
            <TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" />
 | 
			
		||||
          </BucketContext.Provider>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -12,13 +12,14 @@ import { useAllowKey } from "../hooks";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import { handleError } from "@/lib/utils";
 | 
			
		||||
import { useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import { useBucketContext } from "../context";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  id?: string;
 | 
			
		||||
  currentKeys?: string[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const AllowKeyDialog = ({ id, currentKeys }: Props) => {
 | 
			
		||||
const AllowKeyDialog = ({ currentKeys }: Props) => {
 | 
			
		||||
  const { bucket } = useBucketContext();
 | 
			
		||||
  const { dialogRef, isOpen, onOpen, onClose } = useDisclosure();
 | 
			
		||||
  const { data: keys } = useKeys();
 | 
			
		||||
  const form = useForm<AllowKeysSchema>({
 | 
			
		||||
@ -30,12 +31,12 @@ const AllowKeyDialog = ({ id, currentKeys }: Props) => {
 | 
			
		||||
  });
 | 
			
		||||
  const queryClient = useQueryClient();
 | 
			
		||||
 | 
			
		||||
  const allowKey = useAllowKey(id, {
 | 
			
		||||
  const allowKey = useAllowKey(bucket.id, {
 | 
			
		||||
    onSuccess: () => {
 | 
			
		||||
      form.reset();
 | 
			
		||||
      onClose();
 | 
			
		||||
      toast.success("Key allowed!");
 | 
			
		||||
      queryClient.invalidateQueries({ queryKey: ["bucket", id] });
 | 
			
		||||
      queryClient.invalidateQueries({ queryKey: ["bucket", bucket.id] });
 | 
			
		||||
    },
 | 
			
		||||
    onError: handleError,
 | 
			
		||||
  });
 | 
			
		||||
@ -83,7 +84,7 @@ const AllowKeyDialog = ({ id, currentKeys }: Props) => {
 | 
			
		||||
        Allow Key
 | 
			
		||||
      </Button>
 | 
			
		||||
 | 
			
		||||
      <Modal ref={dialogRef} backdrop open={isOpen}>
 | 
			
		||||
      <Modal ref={dialogRef} backdrop open={isOpen} className="max-w-2xl">
 | 
			
		||||
        <Modal.Header className="mb-1">Allow Key</Modal.Header>
 | 
			
		||||
        <Modal.Body>
 | 
			
		||||
          <p>Enter the key you want to allow access to.</p>
 | 
			
		||||
@ -99,6 +100,7 @@ const AllowKeyDialog = ({ id, currentKeys }: Props) => {
 | 
			
		||||
                  />
 | 
			
		||||
                  Key
 | 
			
		||||
                </label>
 | 
			
		||||
                <label>Local Aliases</label>
 | 
			
		||||
                <label className="flex items-center gap-2 cursor-pointer">
 | 
			
		||||
                  <Checkbox
 | 
			
		||||
                    color="primary"
 | 
			
		||||
@ -128,23 +130,31 @@ const AllowKeyDialog = ({ id, currentKeys }: Props) => {
 | 
			
		||||
              <Table.Body>
 | 
			
		||||
                {!keyFields.length ? (
 | 
			
		||||
                  <tr>
 | 
			
		||||
                    <td colSpan={4} className="text-center">
 | 
			
		||||
                    <td colSpan={5} className="text-center">
 | 
			
		||||
                      No keys found
 | 
			
		||||
                    </td>
 | 
			
		||||
                  </tr>
 | 
			
		||||
                ) : null}
 | 
			
		||||
                {keyFields.map((field, index) => (
 | 
			
		||||
                  <Table.Row key={field.id}>
 | 
			
		||||
                    <CheckboxField
 | 
			
		||||
                      form={form}
 | 
			
		||||
                      name={`keys.${index}.checked`}
 | 
			
		||||
                      label={field.name || field.keyId?.substring(0, 8)}
 | 
			
		||||
                    />
 | 
			
		||||
                    <CheckboxField form={form} name={`keys.${index}.read`} />
 | 
			
		||||
                    <CheckboxField form={form} name={`keys.${index}.write`} />
 | 
			
		||||
                    <CheckboxField form={form} name={`keys.${index}.owner`} />
 | 
			
		||||
                  </Table.Row>
 | 
			
		||||
                ))}
 | 
			
		||||
                {keyFields.map((field, index) => {
 | 
			
		||||
                  const curKey = bucket.keys.find(
 | 
			
		||||
                    (key) => key.accessKeyId === field.keyId
 | 
			
		||||
                  );
 | 
			
		||||
                  return (
 | 
			
		||||
                    <Table.Row key={field.id}>
 | 
			
		||||
                      <CheckboxField
 | 
			
		||||
                        form={form}
 | 
			
		||||
                        name={`keys.${index}.checked`}
 | 
			
		||||
                        label={field.name || field.keyId?.substring(0, 8)}
 | 
			
		||||
                      />
 | 
			
		||||
                      <span>
 | 
			
		||||
                        {curKey?.bucketLocalAliases?.join(", ") || "-"}
 | 
			
		||||
                      </span>
 | 
			
		||||
                      <CheckboxField form={form} name={`keys.${index}.read`} />
 | 
			
		||||
                      <CheckboxField form={form} name={`keys.${index}.write`} />
 | 
			
		||||
                      <CheckboxField form={form} name={`keys.${index}.owner`} />
 | 
			
		||||
                    </Table.Row>
 | 
			
		||||
                  );
 | 
			
		||||
                })}
 | 
			
		||||
              </Table.Body>
 | 
			
		||||
            </Table>
 | 
			
		||||
          </div>
 | 
			
		||||
@ -1,5 +1,4 @@
 | 
			
		||||
import { useParams } from "react-router-dom";
 | 
			
		||||
import { useBucket, useDenyKey } from "../hooks";
 | 
			
		||||
import { useDenyKey } from "../hooks";
 | 
			
		||||
import { Card, Checkbox, Table } from "react-daisyui";
 | 
			
		||||
import Button from "@/components/ui/button";
 | 
			
		||||
import { Trash } from "lucide-react";
 | 
			
		||||
@ -7,12 +6,12 @@ import AllowKeyDialog from "./allow-key-dialog";
 | 
			
		||||
import { useMemo } from "react";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import { handleError } from "@/lib/utils";
 | 
			
		||||
import { useBucketContext } from "../context";
 | 
			
		||||
 | 
			
		||||
const PermissionsTab = () => {
 | 
			
		||||
  const { id } = useParams();
 | 
			
		||||
  const { data, refetch } = useBucket(id);
 | 
			
		||||
  const { bucket, refetch } = useBucketContext();
 | 
			
		||||
 | 
			
		||||
  const denyKey = useDenyKey(id, {
 | 
			
		||||
  const denyKey = useDenyKey(bucket.id, {
 | 
			
		||||
    onSuccess: () => {
 | 
			
		||||
      toast.success("Key removed!");
 | 
			
		||||
      refetch();
 | 
			
		||||
@ -21,13 +20,13 @@ const PermissionsTab = () => {
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const keys = useMemo(() => {
 | 
			
		||||
    return data?.keys.filter(
 | 
			
		||||
    return bucket?.keys.filter(
 | 
			
		||||
      (key) =>
 | 
			
		||||
        key.permissions.read !== false ||
 | 
			
		||||
        key.permissions.write !== false ||
 | 
			
		||||
        key.permissions.owner !== false
 | 
			
		||||
    );
 | 
			
		||||
  }, [data?.keys]);
 | 
			
		||||
  }, [bucket?.keys]);
 | 
			
		||||
 | 
			
		||||
  const onRemove = (id: string) => {
 | 
			
		||||
    if (window.confirm("Are you sure you want to remove this key?")) {
 | 
			
		||||
@ -43,10 +42,7 @@ const PermissionsTab = () => {
 | 
			
		||||
      <Card className="card-body">
 | 
			
		||||
        <div className="flex flex-row items-center gap-2">
 | 
			
		||||
          <Card.Title className="flex-1 truncate">Access Keys</Card.Title>
 | 
			
		||||
          <AllowKeyDialog
 | 
			
		||||
            id={id}
 | 
			
		||||
            currentKeys={keys?.map((key) => key.accessKeyId)}
 | 
			
		||||
          />
 | 
			
		||||
          <AllowKeyDialog currentKeys={keys?.map((key) => key.accessKeyId)} />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div className="overflow-x-auto">
 | 
			
		||||
@ -54,6 +50,7 @@ const PermissionsTab = () => {
 | 
			
		||||
            <Table.Head>
 | 
			
		||||
              <span>#</span>
 | 
			
		||||
              <span>Key</span>
 | 
			
		||||
              <span>Aliases</span>
 | 
			
		||||
              <span>Read</span>
 | 
			
		||||
              <span>Write</span>
 | 
			
		||||
              <span>Owner</span>
 | 
			
		||||
@ -65,6 +62,7 @@ const PermissionsTab = () => {
 | 
			
		||||
                <Table.Row>
 | 
			
		||||
                  <span>{idx + 1}</span>
 | 
			
		||||
                  <span>{key.name || key.accessKeyId?.substring(0, 8)}</span>
 | 
			
		||||
                  <span>{key.bucketLocalAliases?.join(", ") || "-"}</span>
 | 
			
		||||
                  <span>
 | 
			
		||||
                    <Checkbox
 | 
			
		||||
                      checked={key.permissions?.read}
 | 
			
		||||
@ -10,16 +10,29 @@ const BucketsPage = () => {
 | 
			
		||||
  const [search, setSearch] = useState("");
 | 
			
		||||
 | 
			
		||||
  const items = useMemo(() => {
 | 
			
		||||
    if (!search?.length) {
 | 
			
		||||
      return data;
 | 
			
		||||
    let buckets =
 | 
			
		||||
      data?.map((bucket) => {
 | 
			
		||||
        return {
 | 
			
		||||
          ...bucket,
 | 
			
		||||
          aliases: [
 | 
			
		||||
            ...(bucket.globalAliases || []),
 | 
			
		||||
            ...(bucket.localAliases?.map((l) => l.alias) || []),
 | 
			
		||||
          ],
 | 
			
		||||
        };
 | 
			
		||||
      }) || [];
 | 
			
		||||
 | 
			
		||||
    if (search?.length > 0) {
 | 
			
		||||
      const q = search.toLowerCase();
 | 
			
		||||
      buckets = buckets.filter(
 | 
			
		||||
        (bucket) =>
 | 
			
		||||
          bucket.id.includes(q) ||
 | 
			
		||||
          bucket.aliases.find((alias) => alias.includes(q))
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const q = search.toLowerCase();
 | 
			
		||||
    return data?.filter(
 | 
			
		||||
      (bucket) =>
 | 
			
		||||
        bucket.id.includes(q) ||
 | 
			
		||||
        bucket.globalAliases.find((alias) => alias.includes(q))
 | 
			
		||||
    );
 | 
			
		||||
    buckets = buckets.sort((a, b) => a.aliases[0].localeCompare(b.aliases[0]));
 | 
			
		||||
 | 
			
		||||
    return buckets;
 | 
			
		||||
  }, [data, search]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
 | 
			
		||||
@ -5,6 +5,7 @@ export type GetBucketRes = Bucket[];
 | 
			
		||||
export type Bucket = {
 | 
			
		||||
  id: string;
 | 
			
		||||
  globalAliases: string[];
 | 
			
		||||
  localAliases: LocalAlias[];
 | 
			
		||||
  websiteAccess: boolean;
 | 
			
		||||
  websiteConfig?: WebsiteConfig | null;
 | 
			
		||||
  keys: Key[];
 | 
			
		||||
@ -17,11 +18,16 @@ export type Bucket = {
 | 
			
		||||
  quotas: Quotas;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type LocalAlias = {
 | 
			
		||||
  accessKeyId: string;
 | 
			
		||||
  alias: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type Key = {
 | 
			
		||||
  accessKeyId: string;
 | 
			
		||||
  name: string;
 | 
			
		||||
  permissions: Permissions;
 | 
			
		||||
  bucketLocalAliases: any[];
 | 
			
		||||
  bucketLocalAliases: string[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type Permissions = {
 | 
			
		||||
 | 
			
		||||
@ -66,7 +66,8 @@ const AssignNodeDialog = () => {
 | 
			
		||||
  }, [data]);
 | 
			
		||||
 | 
			
		||||
  const zoneList = useMemo(() => {
 | 
			
		||||
    const list = cluster?.nodes
 | 
			
		||||
    const nodes = cluster?.nodes || cluster?.knownNodes || [];
 | 
			
		||||
    const list = nodes
 | 
			
		||||
      .flatMap((i) => {
 | 
			
		||||
        const role = layout?.roles.find((role) => role.id === i.id);
 | 
			
		||||
        const staged = layout?.stagedRoleChanges.find(
 | 
			
		||||
@ -83,7 +84,8 @@ const AssignNodeDialog = () => {
 | 
			
		||||
  }, [cluster, layout]);
 | 
			
		||||
 | 
			
		||||
  const tagsList = useMemo(() => {
 | 
			
		||||
    const list = cluster?.nodes
 | 
			
		||||
    const nodes = cluster?.nodes || cluster?.knownNodes || [];
 | 
			
		||||
    const list = nodes
 | 
			
		||||
      .flatMap((i) => {
 | 
			
		||||
        const role = layout?.roles.find((role) => role.id === i.id);
 | 
			
		||||
        const staged = layout?.stagedRoleChanges.find(
 | 
			
		||||
 | 
			
		||||
@ -108,7 +108,7 @@ const NodesList = ({ nodes }: NodeListProps) => {
 | 
			
		||||
 | 
			
		||||
  const onRevert = () => {
 | 
			
		||||
    if (
 | 
			
		||||
      window.confirm("Are you sure you want to revert layout changes?") &&
 | 
			
		||||
      window.confirm("Are you sure you want to revert any changes made?") &&
 | 
			
		||||
      data?.version != null
 | 
			
		||||
    ) {
 | 
			
		||||
      revertChanges.mutate(data?.version + 1);
 | 
			
		||||
@ -117,7 +117,7 @@ const NodesList = ({ nodes }: NodeListProps) => {
 | 
			
		||||
 | 
			
		||||
  const onApply = () => {
 | 
			
		||||
    if (
 | 
			
		||||
      window.confirm("Are you sure you want to revert layout changes?") &&
 | 
			
		||||
      window.confirm("Are you sure you want to apply your layout changes?") &&
 | 
			
		||||
      data?.version != null
 | 
			
		||||
    ) {
 | 
			
		||||
      applyChanges.mutate(data?.version + 1);
 | 
			
		||||
@ -184,7 +184,7 @@ const NodesList = ({ nodes }: NodeListProps) => {
 | 
			
		||||
        </Alert>
 | 
			
		||||
      ) : null}
 | 
			
		||||
 | 
			
		||||
      <div className="w-full overflow-x-auto min-h-[400px] pb-16">
 | 
			
		||||
      <div className="w-full overflow-x-auto overflow-y-hidden min-h-[400px]">
 | 
			
		||||
        <Table size="sm" className="min-w-[800px]">
 | 
			
		||||
          <Table.Head>
 | 
			
		||||
            <span>#</span>
 | 
			
		||||
@ -247,7 +247,7 @@ const NodesList = ({ nodes }: NodeListProps) => {
 | 
			
		||||
                          ` (${Math.round(
 | 
			
		||||
                            (item.dataPartition.available /
 | 
			
		||||
                              item.dataPartition.total) *
 | 
			
		||||
                              100
 | 
			
		||||
                            100
 | 
			
		||||
                          )}%)`}
 | 
			
		||||
                      </p>
 | 
			
		||||
                    </div>
 | 
			
		||||
@ -262,11 +262,16 @@ const NodesList = ({ nodes }: NodeListProps) => {
 | 
			
		||||
                  {item.draining
 | 
			
		||||
                    ? "Draining"
 | 
			
		||||
                    : item.isUp
 | 
			
		||||
                    ? "Active"
 | 
			
		||||
                    : "Inactive"}
 | 
			
		||||
                      ? "Active"
 | 
			
		||||
                      : "Inactive"}
 | 
			
		||||
                </Badge>
 | 
			
		||||
 | 
			
		||||
                <Dropdown end>
 | 
			
		||||
                <Dropdown
 | 
			
		||||
                  end
 | 
			
		||||
                  vertical={
 | 
			
		||||
                    idx > 2 && idx >= items.length - 2 ? "top" : "bottom"
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  <Dropdown.Toggle button={false}>
 | 
			
		||||
                    <Button shape="circle" color="ghost">
 | 
			
		||||
                      <EllipsisVertical />
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,7 @@ import {
 | 
			
		||||
  ApplyLayoutResult,
 | 
			
		||||
  AssignNodeBody,
 | 
			
		||||
  GetClusterLayoutResult,
 | 
			
		||||
  GetNodeInfoResult,
 | 
			
		||||
  GetStatusResult,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import {
 | 
			
		||||
@ -11,24 +12,37 @@ import {
 | 
			
		||||
  useQuery,
 | 
			
		||||
} from "@tanstack/react-query";
 | 
			
		||||
 | 
			
		||||
export const useNodeInfo = () => {
 | 
			
		||||
  return useQuery({
 | 
			
		||||
    queryKey: ["node-info"],
 | 
			
		||||
    queryFn: () =>
 | 
			
		||||
      api.get<GetNodeInfoResult>("/v2/GetNodeInfo", {
 | 
			
		||||
        params: { node: "self" },
 | 
			
		||||
      }),
 | 
			
		||||
    select: (data) => Object.values(data?.success || {})?.[0],
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useClusterStatus = () => {
 | 
			
		||||
  return useQuery({
 | 
			
		||||
    queryKey: ["status"],
 | 
			
		||||
    queryFn: () => api.get<GetStatusResult>("/v1/status"),
 | 
			
		||||
    queryFn: () => api.get<GetStatusResult>("/v2/GetClusterStatus"),
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useClusterLayout = () => {
 | 
			
		||||
  return useQuery({
 | 
			
		||||
    queryKey: ["layout"],
 | 
			
		||||
    queryFn: () => api.get<GetClusterLayoutResult>("/v1/layout"),
 | 
			
		||||
    queryFn: () => api.get<GetClusterLayoutResult>("/v2/GetClusterLayout"),
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useConnectNode = (options?: Partial<UseMutationOptions>) => {
 | 
			
		||||
  return useMutation<any, Error, string>({
 | 
			
		||||
    mutationFn: async (nodeId) => {
 | 
			
		||||
      const [res] = await api.post("/v1/connect", { body: [nodeId] });
 | 
			
		||||
      const [res] = await api.post("/v2/ConnectClusterNodes", {
 | 
			
		||||
        body: [nodeId],
 | 
			
		||||
      });
 | 
			
		||||
      if (!res.success) {
 | 
			
		||||
        throw new Error(res.error || "Unknown error");
 | 
			
		||||
      }
 | 
			
		||||
@ -40,7 +54,10 @@ export const useConnectNode = (options?: Partial<UseMutationOptions>) => {
 | 
			
		||||
 | 
			
		||||
export const useAssignNode = (options?: Partial<UseMutationOptions>) => {
 | 
			
		||||
  return useMutation<any, Error, AssignNodeBody>({
 | 
			
		||||
    mutationFn: (data) => api.post("/v1/layout", { body: [data] }),
 | 
			
		||||
    mutationFn: (data) =>
 | 
			
		||||
      api.post("/v2/UpdateClusterLayout", {
 | 
			
		||||
        body: { parameters: null, roles: [data] },
 | 
			
		||||
      }),
 | 
			
		||||
    ...(options as any),
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
@ -48,15 +65,16 @@ export const useAssignNode = (options?: Partial<UseMutationOptions>) => {
 | 
			
		||||
export const useUnassignNode = (options?: Partial<UseMutationOptions>) => {
 | 
			
		||||
  return useMutation<any, Error, string>({
 | 
			
		||||
    mutationFn: (nodeId) =>
 | 
			
		||||
      api.post("/v1/layout", { body: [{ id: nodeId, remove: true }] }),
 | 
			
		||||
      api.post("/v2/UpdateClusterLayout", {
 | 
			
		||||
        body: { parameters: null, roles: [{ id: nodeId, remove: true }] },
 | 
			
		||||
      }),
 | 
			
		||||
    ...(options as any),
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useRevertChanges = (options?: Partial<UseMutationOptions>) => {
 | 
			
		||||
  return useMutation<any, Error, number>({
 | 
			
		||||
    mutationFn: (version) =>
 | 
			
		||||
      api.post("/v1/layout/revert", { body: { version } }),
 | 
			
		||||
    mutationFn: () => api.post("/v2/RevertClusterLayout"),
 | 
			
		||||
    ...(options as any),
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
@ -64,7 +82,7 @@ export const useRevertChanges = (options?: Partial<UseMutationOptions>) => {
 | 
			
		||||
export const useApplyChanges = (options?: Partial<UseMutationOptions>) => {
 | 
			
		||||
  return useMutation<ApplyLayoutResult, Error, number>({
 | 
			
		||||
    mutationFn: (version) =>
 | 
			
		||||
      api.post("/v1/layout/apply", { body: { version } }),
 | 
			
		||||
      api.post("/v2/ApplyClusterLayout", { body: { version } }),
 | 
			
		||||
    ...(options as any),
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,25 @@
 | 
			
		||||
import Page from "@/context/page-context";
 | 
			
		||||
import { useClusterStatus } from "./hooks";
 | 
			
		||||
import { useClusterStatus, useNodeInfo } from "./hooks";
 | 
			
		||||
import { Card } from "react-daisyui";
 | 
			
		||||
import NodesList from "./components/nodes-list";
 | 
			
		||||
import { useMemo } from "react";
 | 
			
		||||
 | 
			
		||||
const ClusterPage = () => {
 | 
			
		||||
  const { data } = useClusterStatus();
 | 
			
		||||
  const { data: node } = useNodeInfo();
 | 
			
		||||
 | 
			
		||||
  const nodes = useMemo(() => {
 | 
			
		||||
    if (!data) return [];
 | 
			
		||||
 | 
			
		||||
    if (Array.isArray(data.knownNodes)) {
 | 
			
		||||
      return data.knownNodes.map((node) => ({
 | 
			
		||||
        ...node,
 | 
			
		||||
        role: data.layout?.roles.find((role) => role.id === node.id),
 | 
			
		||||
      }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return data.nodes || [];
 | 
			
		||||
  }, [data]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="container">
 | 
			
		||||
@ -14,11 +29,14 @@ const ClusterPage = () => {
 | 
			
		||||
        <Card.Body className="gap-1">
 | 
			
		||||
          <Card.Title className="mb-2">Details</Card.Title>
 | 
			
		||||
 | 
			
		||||
          <DetailItem title="Node ID" value={data?.node} />
 | 
			
		||||
          <DetailItem title="Version" value={data?.garageVersion} />
 | 
			
		||||
          {/* <DetailItem title="Node ID" value={node?.nodeId} /> */}
 | 
			
		||||
          <DetailItem title="Garage Version" value={node?.garageVersion} />
 | 
			
		||||
          {/* <DetailItem title="Rust version" value={data?.rustVersion} /> */}
 | 
			
		||||
          <DetailItem title="DB engine" value={data?.dbEngine} />
 | 
			
		||||
          <DetailItem title="Layout version" value={data?.layoutVersion} />
 | 
			
		||||
          <DetailItem title="DB engine" value={node?.dbEngine} />
 | 
			
		||||
          <DetailItem
 | 
			
		||||
            title="Layout version"
 | 
			
		||||
            value={data?.layoutVersion || data?.layout?.version || "-"}
 | 
			
		||||
          />
 | 
			
		||||
        </Card.Body>
 | 
			
		||||
      </Card>
 | 
			
		||||
 | 
			
		||||
@ -26,7 +44,7 @@ const ClusterPage = () => {
 | 
			
		||||
        <Card.Body>
 | 
			
		||||
          <Card.Title>Nodes</Card.Title>
 | 
			
		||||
 | 
			
		||||
          <NodesList nodes={data?.nodes || []} />
 | 
			
		||||
          <NodesList nodes={nodes} />
 | 
			
		||||
        </Card.Body>
 | 
			
		||||
      </Card>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,19 @@
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
export type GetNodeInfoResult = {
 | 
			
		||||
  success: {
 | 
			
		||||
    [key: string]: {
 | 
			
		||||
      nodeId:         string;
 | 
			
		||||
      garageVersion:  string;
 | 
			
		||||
      garageFeatures: string[];
 | 
			
		||||
      rustVersion:    string;
 | 
			
		||||
      dbEngine:       string;
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
  error:   Error;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export type GetStatusResult = {
 | 
			
		||||
  node: string;
 | 
			
		||||
  garageVersion: string;
 | 
			
		||||
@ -7,7 +21,9 @@ export type GetStatusResult = {
 | 
			
		||||
  rustVersion: string;
 | 
			
		||||
  dbEngine: string;
 | 
			
		||||
  layoutVersion: number;
 | 
			
		||||
  nodes: Node[];
 | 
			
		||||
  nodes?: Node[];
 | 
			
		||||
  knownNodes?: Node[];
 | 
			
		||||
  layout?: GetClusterLayoutResult;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type Node = {
 | 
			
		||||
@ -17,9 +33,9 @@ export type Node = {
 | 
			
		||||
  hostname: string;
 | 
			
		||||
  isUp: boolean;
 | 
			
		||||
  lastSeenSecsAgo: number | null;
 | 
			
		||||
  draining: boolean;
 | 
			
		||||
  dataPartition: DataPartition;
 | 
			
		||||
  metadataPartition: DataPartition;
 | 
			
		||||
  draining?: boolean;
 | 
			
		||||
  dataPartition?: DataPartition;
 | 
			
		||||
  metadataPartition?: DataPartition;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type DataPartition = {
 | 
			
		||||
 | 
			
		||||
@ -5,6 +5,6 @@ import { useQuery } from "@tanstack/react-query";
 | 
			
		||||
export const useNodesHealth = () => {
 | 
			
		||||
  return useQuery({
 | 
			
		||||
    queryKey: ["health"],
 | 
			
		||||
    queryFn: () => api.get<GetHealthResult>("/v1/health"),
 | 
			
		||||
    queryFn: () => api.get<GetHealthResult>("/v2/GetClusterHealth"),
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -38,8 +38,8 @@ const HomePage = () => {
 | 
			
		||||
            health?.status === "healthy"
 | 
			
		||||
              ? "text-success"
 | 
			
		||||
              : health?.status === "degraded"
 | 
			
		||||
              ? "text-warning"
 | 
			
		||||
              : "text-error"
 | 
			
		||||
                ? "text-warning"
 | 
			
		||||
                : "text-error"
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
        <StatsCard title="Nodes" icon={HardDrive} value={health?.knownNodes} />
 | 
			
		||||
@ -56,7 +56,7 @@ const HomePage = () => {
 | 
			
		||||
        <StatsCard
 | 
			
		||||
          title="Active Storage Nodes"
 | 
			
		||||
          icon={DatabaseZap}
 | 
			
		||||
          value={health?.storageNodesOk}
 | 
			
		||||
          value={health?.storageNodesUp}
 | 
			
		||||
        />
 | 
			
		||||
        <StatsCard
 | 
			
		||||
          title="Partitions"
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,7 @@ export type GetHealthResult = {
 | 
			
		||||
  knownNodes: number;
 | 
			
		||||
  connectedNodes: number;
 | 
			
		||||
  storageNodes: number;
 | 
			
		||||
  storageNodesOk: number;
 | 
			
		||||
  storageNodesUp: number;
 | 
			
		||||
  partitions: number;
 | 
			
		||||
  partitionsQuorum: number;
 | 
			
		||||
  partitionsAllOk: number;
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,7 @@ import { CreateKeySchema } from "./schema";
 | 
			
		||||
export const useKeys = () => {
 | 
			
		||||
  return useQuery({
 | 
			
		||||
    queryKey: ["keys"],
 | 
			
		||||
    queryFn: () => api.get<Key[]>("/v1/key?list"),
 | 
			
		||||
    queryFn: () => api.get<Key[]>("/v2/ListKeys"),
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -20,9 +20,9 @@ export const useCreateKey = (
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: async (body) => {
 | 
			
		||||
      if (body.isImport) {
 | 
			
		||||
        return api.post("/v1/key/import", { body });
 | 
			
		||||
        return api.post("/v2/ImportKey", { body });
 | 
			
		||||
      }
 | 
			
		||||
      return api.post("/v1/key", { body });
 | 
			
		||||
      return api.post("/v2/CreateKey", { body });
 | 
			
		||||
    },
 | 
			
		||||
    ...options,
 | 
			
		||||
  });
 | 
			
		||||
@ -32,7 +32,7 @@ export const useRemoveKey = (
 | 
			
		||||
  options?: UseMutationOptions<any, Error, string>
 | 
			
		||||
) => {
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: (id) => api.delete("/v1/key", { params: { id } }),
 | 
			
		||||
    mutationFn: (id) => api.post("/v2/DeleteKey", { params: { id } }),
 | 
			
		||||
    ...options,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -24,7 +24,7 @@ const KeysPage = () => {
 | 
			
		||||
 | 
			
		||||
  const fetchSecretKey = useCallback(async (id: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const result = await api.get("/v1/key", {
 | 
			
		||||
      const result = await api.get("/v2/GetKeyInfo", {
 | 
			
		||||
        params: { id, showSecretKey: "true" },
 | 
			
		||||
      });
 | 
			
		||||
      if (!result?.secretAccessKey) {
 | 
			
		||||
@ -96,6 +96,7 @@ const KeysPage = () => {
 | 
			
		||||
                      icon={Eye}
 | 
			
		||||
                      size="sm"
 | 
			
		||||
                      onClick={() => fetchSecretKey(key.id)}
 | 
			
		||||
                      className="shrink-0 min-w-[80px]"
 | 
			
		||||
                    >
 | 
			
		||||
                      View
 | 
			
		||||
                    </Button>
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user