add initial project setup

This commit is contained in:
Noooste
2025-11-24 18:16:25 +01:00
parent 998f17c7d9
commit 6b050f1090
57 changed files with 11526 additions and 3 deletions
+33 -3
View File
@@ -27,6 +27,36 @@ go.work.sum
# env file
.env
# Editor/IDE
# .idea/
# .vscode/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
../.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env*
!config.yaml.example
docker-compose.*.yml
data/
meta/
garage.toml
frontend/docs
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/routes"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/logger"
"github.com/gofiber/fiber/v3/middleware/recover"
)
const version = "0.1.0"
func main() {
// Parse command-line flags
configPath := flag.String("config", "config.yaml", "Path to configuration file")
flag.Parse()
// Load configuration
log.Printf("Loading configuration from: %s", *configPath)
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
log.Printf("Starting Garage UI Backend v%s in %s mode", version, cfg.Server.Environment)
// Initialize services
log.Println("Initializing S3 service...")
s3Service := services.NewS3Service(&cfg.Garage)
log.Printf("Initializing authentication service (mode: %s)...", cfg.Auth.Mode)
authService, err := auth.NewAuthService(&cfg.Auth)
if err != nil {
log.Fatalf("Failed to initialize auth service: %v", err)
}
// Initialize handlers
healthHandler := handlers.NewHealthHandler(version)
bucketHandler := handlers.NewBucketHandler(s3Service)
objectHandler := handlers.NewObjectHandler(s3Service)
userHandler := handlers.NewUserHandler()
// Create Fiber app with configuration
app := fiber.New(fiber.Config{
AppName: "Garage UI Backend v" + version,
DisableStartupMessage: false,
EnablePrintRoutes: cfg.IsDevelopment(),
ErrorHandler: customErrorHandler,
})
// Apply global middleware
app.Use(recover.New()) // Panic recovery
app.Use(logger.New(logger.Config{
Format: "[${time}] ${status} - ${method} ${path} (${latency})\n",
}))
// Setup routes
log.Println("Setting up routes...")
routes.SetupRoutes(
app,
cfg,
authService,
healthHandler,
bucketHandler,
objectHandler,
userHandler,
)
// Start server in a goroutine
go func() {
addr := cfg.GetAddress()
log.Printf("Server listening on %s", addr)
log.Printf("Health check available at: http://%s/health", addr)
log.Printf("API documentation: http://%s/api/v1/", addr)
if err := app.Listen(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}()
// Wait for interrupt signal to gracefully shutdown the server
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
if err := app.Shutdown(); err != nil {
log.Fatalf("Server shutdown failed: %v", err)
}
log.Println("Server stopped gracefully")
}
// customErrorHandler handles errors globally
func customErrorHandler(c fiber.Ctx, err error) error {
// Default to 500 Internal Server Error
code := fiber.StatusInternalServerError
// Check if it's a Fiber error
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
// Log the error
log.Printf("Error: %v", err)
// Return JSON error response
return c.Status(code).JSON(fiber.Map{
"success": false,
"error": fiber.Map{
"code": fmt.Sprintf("ERROR_%d", code),
"message": err.Error(),
},
})
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+171
View File
@@ -0,0 +1,171 @@
# Garage UI - Frontend
Modern React frontend for Garage S3-compatible object storage management.
## Features
- **Dashboard** - Overview of storage metrics and recent activity
- **Bucket Management** - Create, view, and manage S3 buckets
- **Object Browser** - Navigate folders, upload/download files with drag-and-drop
- **Access Control** - Manage API keys and permissions
- **Dark/Light Mode** - System-aware theme with manual toggle
- **Data-Dense UI** - Optimized for power users with comprehensive tables
## Tech Stack
- **Framework**: React 19 + TypeScript
- **Build Tool**: Vite 7
- **Routing**: React Router v6
- **State Management**: Zustand
- **UI Components**: shadcn/ui (built on Radix UI primitives)
- **Styling**: Tailwind CSS v4
- **Forms**: React Hook Form + Zod
- **Tables**: TanStack Table
- **File Upload**: React Dropzone
- **Icons**: Lucide React
## Getting Started
### Prerequisites
- Node.js 18+ and npm
### Installation
1. Install dependencies:
```bash
npm install
```
2. Create environment file:
```bash
cp .env.example .env
```
3. Update `.env` with your backend API URL:
```
VITE_API_URL=http://localhost:8080/api
```
### Development
Start the development server:
```bash
npm run dev
```
The app will be available at http://localhost:3000
### Building for Production
Build the application:
```bash
npm run build
```
Preview the production build:
```bash
npm run preview
```
## Project Structure
```
src/
├── components/
│ ├── ui/ # shadcn/ui base components
│ ├── layout/ # Layout components (Sidebar, Header)
│ ├── buckets/ # Bucket-specific components
│ ├── objects/ # Object browser components
│ └── access/ # Access control components
├── pages/ # Page components
│ ├── Dashboard.tsx
│ ├── Buckets.tsx
│ ├── Objects.tsx
│ └── AccessControl.tsx
├── lib/
│ ├── api.ts # API client with mock data
│ └── utils.ts # Utility functions
├── hooks/ # Custom React hooks
├── types/ # TypeScript type definitions
└── stores/ # Zustand stores
```
## API Integration
The app is currently using mock data. To connect to your Go backend:
1. Ensure your backend is running on `http://localhost:8080`
2. In `src/lib/api.ts`, uncomment the real API calls
3. Comment out or remove the mock data returns
Example:
```typescript
// Before (mock data)
list: async (): Promise<Bucket[]> => {
return mockBuckets;
},
// After (real API)
list: async (): Promise<Bucket[]> => {
const response = await api.get('/buckets');
return response.data;
},
```
## Features by Page
### Dashboard
- Storage metrics overview
- Bucket count and object statistics
- Storage distribution visualization
- Recent buckets list
### Buckets
- List all buckets with search
- Create new buckets
- View bucket details
- Delete buckets
- Region information
### Objects
- Browse objects and folders
- Breadcrumb navigation
- Drag-and-drop file upload
- Multiple file selection
- Download objects
- Delete objects
- Object metadata
### Access Control
- API key management
- Create keys with permissions
- Activate/deactivate keys
- Copy access key IDs
- Permission configuration
- Bucket policies (coming soon)
## Theme Customization
The app uses CSS variables for theming. Customize colors in `src/index.css`:
```css
:root {
--primary: 240 5.9% 10%;
--secondary: 240 4.8% 95.9%;
/* ... more variables */
}
```
## Contributing
This is a custom frontend for Garage storage. Feel free to extend with additional features:
- Analytics dashboards
- Versioning management
- Lifecycle policies UI
- Replication configuration
- Metrics visualization
## License
MIT
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/garage.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5046
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-router-dom": "^7.9.6",
"recharts": "^3.5.0",
"tailwind-merge": "^3.4.0",
"zod": "^4.1.13",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.17",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+24
View File
@@ -0,0 +1,24 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { ThemeProvider } from '@/components/theme-provider';
import { Layout } from '@/components/layout/layout';
import { Dashboard } from '@/pages/Dashboard';
import { Buckets } from '@/pages/Buckets';
import { AccessControl } from '@/pages/AccessControl';
function App() {
return (
<ThemeProvider defaultTheme="system" storageKey="garage-ui-theme">
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="buckets" element={<Buckets />} />
<Route path="access" element={<AccessControl />} />
</Route>
</Routes>
</BrowserRouter>
</ThemeProvider>
);
}
export default App;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
import { PieChart, Pie, Cell, Legend, Tooltip, ResponsiveContainer } from 'recharts';
import type { BucketUsage } from '@/types';
import { formatBytes } from '@/lib/utils';
import { chartColorPalette, getTextColor, getTooltipStyle } from '@/lib/chart-colors';
interface BucketUsageChartProps {
data: BucketUsage[];
}
export function BucketUsageChart({ data }: BucketUsageChartProps) {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
// Check if dark mode is enabled
const checkDarkMode = () => {
setIsDark(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
// Listen for theme changes
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
const colors = isDark ? chartColorPalette.dark : chartColorPalette.light;
const textColor = getTextColor(isDark);
const tooltipStyle = getTooltipStyle(isDark);
const chartData = data.map(item => ({
name: item.bucketName,
value: item.size,
displaySize: formatBytes(item.size),
}));
return (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, displaySize }) => `${name}: ${displaySize}`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
))}
</Pie>
<Tooltip
formatter={(value) => formatBytes(value as number)}
contentStyle={tooltipStyle as React.CSSProperties}
labelStyle={{ color: textColor }}
/>
<Legend wrapperStyle={{ color: textColor }} />
</PieChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import type { ClusterHealth } from '@/types';
import { grafanaColors, getTextColor, getGridColor, getTooltipStyle } from '@/lib/chart-colors';
interface ClusterHealthChartProps {
data: ClusterHealth;
}
export function ClusterHealthChart({ data }: ClusterHealthChartProps) {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const checkDarkMode = () => {
setIsDark(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
const colors = isDark ? grafanaColors.dark : grafanaColors.light;
const textColor = getTextColor(isDark);
const gridColor = getGridColor(isDark);
const tooltipStyle = getTooltipStyle(isDark);
const unhealthyColor = isDark ? '#e8e8e8' : '#d1d5db';
const chartData = [
{
metric: 'Nodes',
healthy: data.healthyStorageNodes,
unhealthy: data.declaredStorageNodes - data.healthyStorageNodes,
},
{
metric: 'Partitions',
healthy: data.healthyPartitions,
unhealthy: data.totalPartitions - data.healthyPartitions,
},
{
metric: 'Connected',
healthy: data.connectedNodes,
unhealthy: data.knownNodes - data.connectedNodes,
},
];
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
<XAxis dataKey="metric" stroke={textColor} />
<YAxis stroke={textColor} />
<Tooltip
contentStyle={tooltipStyle as React.CSSProperties}
labelStyle={{ color: textColor }}
/>
<Legend wrapperStyle={{ color: textColor }} />
<Bar dataKey="healthy" stackId="a" fill={colors.green} name="Healthy" />
<Bar dataKey="unhealthy" stackId="a" fill={unhealthyColor} name="Unhealthy" />
</BarChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import type { RequestMetrics } from '@/types';
import { grafanaColors, getTextColor, getGridColor, getTooltipStyle } from '@/lib/chart-colors';
interface RequestMetricsChartProps {
data: RequestMetrics;
}
export function RequestMetricsChart({ data }: RequestMetricsChartProps) {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const checkDarkMode = () => {
setIsDark(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
const colors = isDark ? grafanaColors.dark : grafanaColors.light;
const textColor = getTextColor(isDark);
const gridColor = getGridColor(isDark);
const tooltipStyle = getTooltipStyle(isDark);
const chartData = [
{
name: 'Requests (24h)',
GET: data.getRequests,
PUT: data.putRequests,
DELETE: data.deleteRequests,
LIST: data.listRequests,
},
];
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
<XAxis dataKey="name" stroke={textColor} />
<YAxis stroke={textColor} />
<Tooltip
formatter={(value) => (value as number).toLocaleString()}
contentStyle={tooltipStyle as React.CSSProperties}
labelStyle={{ color: textColor }}
/>
<Legend wrapperStyle={{ color: textColor }} />
<Bar dataKey="GET" stackId="a" fill={colors.blue} />
<Bar dataKey="PUT" stackId="a" fill={colors.green} />
<Bar dataKey="DELETE" stackId="a" fill={colors.red} />
<Bar dataKey="LIST" stackId="a" fill={colors.orange} />
</BarChart>
</ResponsiveContainer>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { ThemeToggle } from './theme-toggle';
interface HeaderProps {
title: string;
}
export function Header({ title }: HeaderProps) {
return (
<header className="sticky top-0 z-40 border-b bg-background">
<div className="flex h-16 items-center gap-4 px-6">
<div className="flex-1">
<h1 className="text-2xl font-semibold">{title}</h1>
</div>
<div className="flex items-center gap-2">
<div className="relative w-64">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search..."
className="pl-8 w-full"
/>
</div>
<ThemeToggle />
</div>
</div>
</header>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { Outlet } from 'react-router-dom';
import { Sidebar } from './sidebar';
export function Layout() {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
);
}
@@ -0,0 +1,89 @@
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import {
LayoutDashboard,
Database,
Key,
BarChart3,
Settings,
} from 'lucide-react';
interface NavItem {
title: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
{
title: 'Dashboard',
href: '/',
icon: LayoutDashboard,
},
{
title: 'Buckets',
href: '/buckets',
icon: Database,
},
{
title: 'Access Control',
href: '/access',
icon: Key,
},
{
title: 'Analytics',
href: '/analytics',
icon: BarChart3,
},
{
title: 'Settings',
href: '/settings',
icon: Settings,
},
];
export function Sidebar() {
const location = useLocation();
return (
<div className="flex h-full w-64 flex-col border-r bg-card">
<div className="flex h-16 items-center border-b px-6">
<img src="/garage.png" alt="Garage UI Logo" className="h-8 w-8 mr-2" />
<span className="text-lg font-semibold">Garage UI</span>
</div>
<nav className="flex-1 space-y-1 p-4">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.href;
return (
<Link
key={item.href}
to={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<Icon className="h-5 w-5" />
{item.title}
</Link>
);
})}
</nav>
<div className="border-t p-4">
<div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
AD
</div>
<div className="flex-1 overflow-hidden">
<p className="text-sm font-medium truncate">Admin User</p>
<p className="text-xs text-muted-foreground truncate">admin@garage.local</p>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useTheme } from '@/components/theme-provider';
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,71 @@
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light' | 'system';
interface ThemeProviderProps {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
}
interface ThemeProviderState {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'garage-ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
return context;
};
+32
View File
@@ -0,0 +1,32 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none',
{
variants: {
variant: {
default: 'bg-[#ff9329] text-black hover:bg-[#e58625]',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+55
View File
@@ -0,0 +1,55 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+157
View File
@@ -0,0 +1,157 @@
import * as React from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface DialogContextValue {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const DialogContext = React.createContext<DialogContextValue | undefined>(undefined);
function useDialog() {
const context = React.useContext(DialogContext);
if (!context) {
throw new Error('useDialog must be used within a Dialog');
}
return context;
}
interface DialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}
const Dialog: React.FC<DialogProps> = ({ open = false, onOpenChange, children }) => {
return (
<DialogContext.Provider value={{ open, onOpenChange: onOpenChange || (() => {}) }}>
{children}
</DialogContext.Provider>
);
};
const DialogTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ onClick, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<button
ref={ref}
onClick={(e) => {
onOpenChange(true);
onClick?.(e);
}}
{...props}
/>
);
});
DialogTrigger.displayName = 'DialogTrigger';
const DialogPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { open } = useDialog();
if (!open) return null;
return <>{children}</>;
};
const DialogOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<div
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
onClick={() => onOpenChange(false)}
{...props}
/>
);
}
);
DialogOverlay.displayName = 'DialogOverlay';
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<DialogPortal>
<DialogOverlay />
<div className="fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] w-full max-w-lg">
<div
ref={ref}
style={{ backgroundColor: 'hsl(var(--background))' }}
className={cn(
'relative p-6 shadow-lg duration-200 rounded-lg border',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className
)}
{...props}
>
{children}
<button
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 rounded-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</div>
</div>
</DialogPortal>
);
}
);
DialogContent.displayName = 'DialogContent';
const DialogHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => <div className={cn('flex flex-col space-y-1.5 text-center sm:text-left bg-background', className)} {...props} />;
DialogHeader.displayName = 'DialogHeader';
const DialogFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
DialogTitle.displayName = 'DialogTitle';
const DialogDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
DialogDescription.displayName = 'DialogDescription';
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
@@ -0,0 +1,144 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface DropdownMenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<HTMLButtonElement>;
}
const DropdownMenuContext = React.createContext<DropdownMenuContextValue | undefined>(undefined);
function useDropdownMenu() {
const context = React.useContext(DropdownMenuContext);
if (!context) {
throw new Error('useDropdownMenu must be used within a DropdownMenu');
}
return context;
}
interface DropdownMenuProps {
children: React.ReactNode;
}
const DropdownMenu: React.FC<DropdownMenuProps> = ({ children }) => {
const [open, setOpen] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement>(null);
return (
<DropdownMenuContext.Provider value={{ open, setOpen, triggerRef }}>
<div className="relative inline-block text-left">{children}</div>
</DropdownMenuContext.Provider>
);
};
const DropdownMenuTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ onClick, ...props }, ref) => {
const { open, setOpen, triggerRef } = useDropdownMenu();
// Merge the forwarded ref with the context triggerRef
React.useImperativeHandle(ref, () => triggerRef.current as HTMLButtonElement);
return (
<button
ref={triggerRef}
onClick={(e) => {
setOpen(!open);
onClick?.(e);
}}
{...props}
/>
);
});
DropdownMenuTrigger.displayName = 'DropdownMenuTrigger';
interface DropdownMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {
align?: 'start' | 'end' | 'center';
}
const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContentProps>(
({ className, children, align = 'start', ...props }, _ref) => {
const { open, setOpen, triggerRef } = useDropdownMenu();
const contentRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const isClickOnTrigger = triggerRef.current?.contains(event.target as Node);
const isClickOnContent = contentRef.current?.contains(event.target as Node);
if (!isClickOnContent && !isClickOnTrigger) {
setOpen(false);
}
};
if (open) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [open, setOpen, triggerRef]);
if (!open) return null;
const alignmentClasses = {
start: 'left-0',
end: 'right-0',
center: 'left-1/2 -translate-x-1/2',
};
return (
<div
ref={contentRef}
style={{ backgroundColor: 'hsl(var(--popover))' }}
className={cn(
'absolute z-50 mt-2 w-56 origin-top-right rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
alignmentClasses[align],
className
)}
{...props}
>
<div className="py-1">{children}</div>
</div>
);
}
);
DropdownMenuContent.displayName = 'DropdownMenuContent';
const DropdownMenuItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, onClick, ...props }, ref) => {
const { setOpen } = useDropdownMenu();
return (
<div
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none',
className
)}
onClick={(e) => {
onClick?.(e);
setOpen(false);
}}
{...props}
/>
);
}
);
DropdownMenuItem.displayName = 'DropdownMenuItem';
const DropdownMenuSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
)
);
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
};
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };
+90
View File
@@ -0,0 +1,90 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
)
);
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('border-t bg-muted font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted data-[state=selected]:bg-muted',
className
)}
{...props}
/>
)
);
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
));
TableCaption.displayName = 'TableCaption';
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+118
View File
@@ -0,0 +1,118 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface TabsContextValue {
value: string;
onValueChange: (value: string) => void;
}
const TabsContext = React.createContext<TabsContextValue | undefined>(undefined);
function useTabs() {
const context = React.useContext(TabsContext);
if (!context) {
throw new Error('useTabs must be used within a Tabs');
}
return context;
}
interface TabsProps {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
children: React.ReactNode;
className?: string;
}
const Tabs: React.FC<TabsProps> = ({
defaultValue,
value: controlledValue,
onValueChange,
children,
className,
}) => {
const [internalValue, setInternalValue] = React.useState(defaultValue || '');
const value = controlledValue !== undefined ? controlledValue : internalValue;
const handleValueChange = (newValue: string) => {
if (controlledValue === undefined) {
setInternalValue(newValue);
}
onValueChange?.(newValue);
};
return (
<TabsContext.Provider value={{ value, onValueChange: handleValueChange }}>
<div className={className}>{children}</div>
</TabsContext.Provider>
);
};
const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
)
);
TabsList.displayName = 'TabsList';
interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
}
const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
({ className, value: triggerValue, onClick, ...props }, ref) => {
const { value, onValueChange } = useTabs();
const isActive = value === triggerValue;
return (
<button
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none',
isActive
? 'bg-background text-foreground shadow-sm'
: 'hover:bg-accent hover:text-accent-foreground',
className
)}
onClick={(e) => {
onValueChange(triggerValue);
onClick?.(e);
}}
{...props}
/>
);
}
);
TabsTrigger.displayName = 'TabsTrigger';
interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
}
const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
({ className, value: contentValue, ...props }, ref) => {
const { value } = useTabs();
if (value !== contentValue) return null;
return (
<div
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
);
}
);
TabsContent.displayName = 'TabsContent';
export { Tabs, TabsList, TabsTrigger, TabsContent };
+103
View File
@@ -0,0 +1,103 @@
@import 'tailwindcss';
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 98%;
--popover-foreground: 240 10% 3.9%;
--primary: 28 100% 58%;
--primary-foreground: 0 0% 100%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 28 100% 58%;
--radius: 0.5rem;
/* Grafana Chart Colors */
--chart-blue: #1f77b4;
--chart-orange: #ff7f0e;
--chart-green: #2ca02c;
--chart-red: #d62728;
--chart-purple: #9467bd;
--chart-brown: #8c564b;
--chart-pink: #e377c2;
--chart-gray: #7f7f7f;
--chart-olive: #bcbd22;
--chart-cyan: #17becf;
}
.dark {
/* VS Code Dark Blue Theme */
--background: 222 14% 11%;
--foreground: 220 13% 91%;
--card: 222 13% 16%;
--card-foreground: 220 13% 91%;
--popover: 222 13% 18%;
--popover-foreground: 220 13% 91%;
--primary: 28 100% 58%;
--primary-foreground: 0 0% 100%;
--secondary: 222 13% 23%;
--secondary-foreground: 220 13% 91%;
--muted: 222 13% 30%;
--muted-foreground: 220 9% 64%;
--accent: 28 100% 58%;
--accent-foreground: 0 0% 100%;
--destructive: 15 86% 56%;
--destructive-foreground: 220 13% 91%;
--border: 222 13% 23%;
--input: 222 13% 23%;
--ring: 28 100% 58%;
/* Grafana Chart Colors */
--chart-blue: #3eb0ff;
--chart-orange: #ff9830;
--chart-green: #73bf69;
--chart-red: #f2495c;
--chart-purple: #b581d8;
--chart-brown: #c4a2e0;
--chart-pink: #ff9d96;
--chart-gray: #9ba3af;
--chart-olive: #fac858;
--chart-cyan: #37b7c3;
}
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-feature-settings: 'rlig' 1, 'calt' 1;
}
}
@layer utilities {
.scrollbar-thin::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.2);
border-radius: 0.375rem;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.3);
}
}
+662
View File
@@ -0,0 +1,662 @@
import axios from 'axios';
import type {
Bucket,
BucketDetails,
S3Object,
ObjectMetadata,
AccessKey,
StorageMetrics,
ClusterHealth,
ClusterStatistics,
NodeInfo,
GarageMetrics,
} from '@/types';
// Configure axios instance with base URL
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api',
headers: {
'Content-Type': 'application/json',
},
});
// Add request interceptor for authentication
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth-token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Bucket API
export const bucketsApi = {
list: async (): Promise<Bucket[]> => {
// const response = await api.get('/buckets');
// return response.data;
return mockBuckets; // Using mock data for now
},
get: async (_name: string): Promise<BucketDetails> => {
// const response = await api.get(`/buckets/${_name}`);
// return response.data;
return mockBucketDetails; // Using mock data for now
},
create: async (bucketName: string, bucketRegion?: string): Promise<void> => {
// await api.post('/buckets', { name: bucketName, region: bucketRegion });
console.log('Create bucket:', bucketName, bucketRegion);
},
delete: async (name: string): Promise<void> => {
// await api.delete(`/buckets/${name}`);
console.log('Delete bucket:', name);
},
updateSettings: async (name: string, settings: Partial<BucketDetails>): Promise<void> => {
// await api.put(`/buckets/${name}/settings`, settings);
console.log('Update bucket settings:', name, settings);
},
};
// Objects API
export const objectsApi = {
list: async (_bucket: string, _prefix?: string): Promise<S3Object[]> => {
// const response = await api.get(`/buckets/${_bucket}/objects`, { params: { prefix: _prefix } });
// return response.data;
// Filter mock objects by prefix
if (!_prefix) {
return mockObjectsFlat.filter(obj => !obj.key.includes('/'));
}
// Get all objects with this prefix, but only direct children
const prefixObjects = mockObjectsFlat.filter(obj => obj.key.startsWith(_prefix));
const directChildren = new Set<string>();
prefixObjects.forEach(obj => {
const remaining = obj.key.slice(_prefix.length);
const parts = remaining.split('/');
if (parts.length > 0 && parts[0]) {
if (parts.length === 1 && !remaining.endsWith('/')) {
// It's a file at this level
directChildren.add(obj.key);
} else if (parts.length > 1 || remaining.endsWith('/')) {
// It's a folder, add the folder path
const folderPath = _prefix + parts[0] + '/';
directChildren.add(folderPath);
}
}
});
// Return unique objects
const seen = new Set<string>();
return Array.from(directChildren)
.map(key => {
if (seen.has(key)) return null;
seen.add(key);
return mockObjectsFlat.find(obj => obj.key === key) || {
key,
size: 0,
lastModified: new Date().toISOString(),
isFolder: key.endsWith('/'),
};
})
.filter((obj): obj is S3Object => obj !== null);
},
get: async (_bucket: string, _key: string): Promise<Blob> => {
// const response = await api.get(`/buckets/${_bucket}/objects/${_key}`, { responseType: 'blob' });
// return response.data;
return new Blob(); // Using mock data for now
},
getMetadata: async (_bucket: string, _key: string): Promise<ObjectMetadata> => {
// const response = await api.get(`/buckets/${_bucket}/objects/${_key}/metadata`);
// return response.data;
return mockObjectMetadata; // Using mock data for now
},
upload: async (bucket: string, key: string, file: File): Promise<void> => {
// const formData = new FormData();
// formData.append('file', file);
// await api.post(`/buckets/${bucket}/objects/${key}`, formData, {
// headers: { 'Content-Type': 'multipart/form-data' },
// });
console.log('Upload object:', bucket, key, file);
},
delete: async (bucket: string, key: string): Promise<void> => {
// await api.delete(`/buckets/${bucket}/objects/${key}`);
console.log('Delete object:', bucket, key);
},
deleteMultiple: async (bucket: string, keys: string[]): Promise<void> => {
// await api.post(`/buckets/${bucket}/objects/delete`, { keys });
console.log('Delete multiple objects:', bucket, keys);
},
getPresignedUrl: async (_bucket: string, _key: string, _expiresIn?: number): Promise<string> => {
// const response = await api.post(`/buckets/${_bucket}/objects/${_key}/presign`, { expiresIn: _expiresIn });
// return response.data.url;
return 'https://example.com/presigned-url'; // Using mock data for now
},
};
// Access Control API
export const accessApi = {
listKeys: async (): Promise<AccessKey[]> => {
// const response = await api.get('/access/keys');
// return response.data;
return mockAccessKeys; // Using mock data for now
},
createKey: async (name: string, permissions: any[]): Promise<AccessKey> => {
// const response = await api.post('/access/keys', { name, permissions });
// return response.data;
console.log('Create access key:', name, permissions);
return mockAccessKeys[0]; // Using mock data for now
},
updateKey: async (keyId: string, updates: Partial<AccessKey>): Promise<void> => {
// await api.put(`/access/keys/${keyId}`, updates);
console.log('Update access key:', keyId, updates);
},
deleteKey: async (keyId: string): Promise<void> => {
// await api.delete(`/access/keys/${keyId}`);
console.log('Delete access key:', keyId);
},
};
// Analytics API
export const analyticsApi = {
getMetrics: async (): Promise<StorageMetrics> => {
// const response = await api.get('/analytics/metrics');
// return response.data;
return mockMetrics; // Using mock data for now
},
};
// Garage Admin API
export const garageApi = {
getClusterHealth: async (): Promise<ClusterHealth> => {
// const response = await api.get('/v2/GetClusterHealth');
// return response.data;
return mockClusterHealth; // Using mock data for now
},
getClusterStatistics: async (): Promise<ClusterStatistics> => {
// const response = await api.get('/v2/GetClusterStatistics');
// return response.data;
return mockClusterStatistics; // Using mock data for now
},
getNodeInfo: async (): Promise<NodeInfo> => {
// const response = await api.get('/v2/GetNodeInfo?node=self');
// return response.data;
return mockNodeInfo; // Using mock data for now
},
getFullMetrics: async (): Promise<GarageMetrics> => {
// Fetch all cluster-related metrics
const [health, statistics, nodeInfo, storageMetrics] = await Promise.all([
garageApi.getClusterHealth(),
garageApi.getClusterStatistics(),
garageApi.getNodeInfo(),
analyticsApi.getMetrics(),
]);
return {
...storageMetrics,
clusterHealth: health,
clusterStatistics: statistics,
nodeInfo: nodeInfo,
};
},
};
// Mock data
const mockBuckets: Bucket[] = [
{
name: 'production-assets',
creationDate: '2025-01-15T10:30:00Z',
objectCount: 1247,
size: 524288000,
region: 'us-east-1',
},
{
name: 'user-uploads',
creationDate: '2025-01-10T14:20:00Z',
objectCount: 3892,
size: 1073741824,
region: 'us-east-1',
},
{
name: 'backups',
creationDate: '2025-01-05T08:15:00Z',
objectCount: 156,
size: 2147483648,
region: 'us-west-2',
},
{
name: 'logs',
creationDate: '2024-12-20T16:45:00Z',
objectCount: 8934,
size: 314572800,
region: 'eu-west-1',
},
{
name: 'media-cdn',
creationDate: '2024-12-15T11:00:00Z',
objectCount: 5621,
size: 3221225472,
region: 'ap-southeast-1',
},
];
const mockBucketDetails: BucketDetails = {
...mockBuckets[0],
versioning: true,
encryption: true,
publicAccess: false,
lifecycleRules: [
{
id: 'rule-1',
enabled: true,
prefix: 'temp/',
expirationDays: 30,
},
],
};
const mockObjectsFlat: S3Object[] = [
// Root level files
{
key: 'config.json',
size: 2048,
lastModified: '2025-01-22T09:15:00Z',
etag: 'abc123',
storageClass: 'STANDARD',
},
{
key: 'README.md',
size: 4096,
lastModified: '2025-01-21T14:20:00Z',
etag: 'def456',
storageClass: 'STANDARD',
},
{
key: 'app.log',
size: 1048576,
lastModified: '2025-01-24T11:45:00Z',
etag: 'ghi789',
storageClass: 'STANDARD',
},
// Folder entries
{
key: 'images/',
size: 0,
lastModified: '2025-01-20T10:00:00Z',
isFolder: true,
},
{
key: 'documents/',
size: 0,
lastModified: '2025-01-19T15:30:00Z',
isFolder: true,
},
{
key: 'videos/',
size: 0,
lastModified: '2025-01-18T14:00:00Z',
isFolder: true,
},
{
key: 'backups/',
size: 0,
lastModified: '2025-01-17T08:30:00Z',
isFolder: true,
},
// Images subfolder
{
key: 'images/avatar.png',
size: 256000,
lastModified: '2025-01-20T10:15:00Z',
etag: 'img001',
storageClass: 'STANDARD',
},
{
key: 'images/logo.svg',
size: 12288,
lastModified: '2025-01-20T10:20:00Z',
etag: 'img002',
storageClass: 'STANDARD',
},
{
key: 'images/banner.jpg',
size: 512000,
lastModified: '2025-01-20T10:25:00Z',
etag: 'img003',
storageClass: 'STANDARD',
},
{
key: 'images/thumbnails/',
size: 0,
lastModified: '2025-01-20T11:00:00Z',
isFolder: true,
},
// Images/thumbnails subfolder
{
key: 'images/thumbnails/avatar-small.png',
size: 32000,
lastModified: '2025-01-20T11:05:00Z',
etag: 'thumb001',
storageClass: 'STANDARD',
},
{
key: 'images/thumbnails/logo-small.svg',
size: 4096,
lastModified: '2025-01-20T11:10:00Z',
etag: 'thumb002',
storageClass: 'STANDARD',
},
{
key: 'images/thumbnails/banner-small.jpg',
size: 64000,
lastModified: '2025-01-20T11:15:00Z',
etag: 'thumb003',
storageClass: 'STANDARD',
},
// Documents subfolder
{
key: 'documents/report-2025-01.pdf',
size: 2048000,
lastModified: '2025-01-19T16:00:00Z',
etag: 'doc001',
storageClass: 'STANDARD',
},
{
key: 'documents/report-2025-02.pdf',
size: 2156000,
lastModified: '2025-01-19T16:10:00Z',
etag: 'doc002',
storageClass: 'STANDARD',
},
{
key: 'documents/contract.docx',
size: 128000,
lastModified: '2025-01-19T16:20:00Z',
etag: 'doc003',
storageClass: 'STANDARD',
},
{
key: 'documents/spreadsheet.xlsx',
size: 256000,
lastModified: '2025-01-19T16:30:00Z',
etag: 'doc004',
storageClass: 'STANDARD',
},
{
key: 'documents/archives/',
size: 0,
lastModified: '2025-01-19T17:00:00Z',
isFolder: true,
},
// Documents/archives subfolder
{
key: 'documents/archives/2024-reports.zip',
size: 10485760,
lastModified: '2025-01-19T17:15:00Z',
etag: 'arch001',
storageClass: 'STANDARD',
},
{
key: 'documents/archives/2023-reports.zip',
size: 9437184,
lastModified: '2025-01-19T17:20:00Z',
etag: 'arch002',
storageClass: 'STANDARD',
},
// Videos subfolder
{
key: 'videos/tutorial-intro.mp4',
size: 104857600,
lastModified: '2025-01-18T14:30:00Z',
etag: 'vid001',
storageClass: 'STANDARD',
},
{
key: 'videos/tutorial-advanced.mp4',
size: 157286400,
lastModified: '2025-01-18T14:35:00Z',
etag: 'vid002',
storageClass: 'STANDARD',
},
{
key: 'videos/demo.webm',
size: 52428800,
lastModified: '2025-01-18T14:40:00Z',
etag: 'vid003',
storageClass: 'STANDARD',
},
{
key: 'videos/clips/',
size: 0,
lastModified: '2025-01-18T15:00:00Z',
isFolder: true,
},
// Videos/clips subfolder
{
key: 'videos/clips/intro.mp4',
size: 20971520,
lastModified: '2025-01-18T15:10:00Z',
etag: 'clip001',
storageClass: 'STANDARD',
},
{
key: 'videos/clips/outro.mp4',
size: 20971520,
lastModified: '2025-01-18T15:15:00Z',
etag: 'clip002',
storageClass: 'STANDARD',
},
{
key: 'videos/clips/transition.mp4',
size: 10485760,
lastModified: '2025-01-18T15:20:00Z',
etag: 'clip003',
storageClass: 'STANDARD',
},
// Backups subfolder
{
key: 'backups/db-backup-2025-01-24.sql',
size: 536870912,
lastModified: '2025-01-24T03:00:00Z',
etag: 'backup001',
storageClass: 'STANDARD',
},
{
key: 'backups/db-backup-2025-01-23.sql',
size: 524288000,
lastModified: '2025-01-23T03:00:00Z',
etag: 'backup002',
storageClass: 'STANDARD',
},
{
key: 'backups/app-backup-2025-01-24.tar.gz',
size: 1073741824,
lastModified: '2025-01-24T03:15:00Z',
etag: 'backup003',
storageClass: 'STANDARD',
},
{
key: 'backups/daily/',
size: 0,
lastModified: '2025-01-24T03:30:00Z',
isFolder: true,
},
// Backups/daily subfolder
{
key: 'backups/daily/db-backup-2025-01-24-00.sql',
size: 268435456,
lastModified: '2025-01-24T00:30:00Z',
etag: 'daily001',
storageClass: 'STANDARD',
},
{
key: 'backups/daily/db-backup-2025-01-24-06.sql',
size: 268435456,
lastModified: '2025-01-24T06:30:00Z',
etag: 'daily002',
storageClass: 'STANDARD',
},
{
key: 'backups/daily/db-backup-2025-01-24-12.sql',
size: 268435456,
lastModified: '2025-01-24T12:30:00Z',
etag: 'daily003',
storageClass: 'STANDARD',
},
{
key: 'backups/daily/db-backup-2025-01-24-18.sql',
size: 268435456,
lastModified: '2025-01-24T18:30:00Z',
etag: 'daily004',
storageClass: 'STANDARD',
},
];
const mockObjectMetadata: ObjectMetadata = {
key: 'config.json',
size: 2048,
lastModified: '2025-01-22T09:15:00Z',
contentType: 'application/json',
etag: 'abc123',
metadata: {
'x-amz-meta-author': 'admin',
'x-amz-meta-version': '1.0',
},
};
const mockAccessKeys: AccessKey[] = [
{
accessKeyId: 'GK1234567890ABCDEF',
name: 'Production API Key',
createdAt: '2025-01-10T10:00:00Z',
lastUsed: '2025-01-24T14:30:00Z',
status: 'active',
permissions: [
{
resource: 'production-assets/*',
actions: ['GetObject', 'PutObject'],
effect: 'Allow',
},
],
},
{
accessKeyId: 'GK0987654321FEDCBA',
name: 'Backup Service',
createdAt: '2025-01-05T08:00:00Z',
lastUsed: '2025-01-24T02:00:00Z',
status: 'active',
permissions: [
{
resource: 'backups/*',
actions: ['GetObject', 'PutObject', 'DeleteObject'],
effect: 'Allow',
},
],
},
{
accessKeyId: 'GK5555666677778888',
name: 'Legacy Integration',
createdAt: '2024-11-15T12:00:00Z',
status: 'inactive',
permissions: [
{
resource: 'user-uploads/*',
actions: ['GetObject'],
effect: 'Allow',
},
],
},
];
const mockMetrics: StorageMetrics = {
totalSize: 7282384896,
objectCount: 19850,
bucketCount: 5,
usageByBucket: [
{
bucketName: 'media-cdn',
size: 3221225472,
objectCount: 5621,
percentage: 44.2,
},
{
bucketName: 'backups',
size: 2147483648,
objectCount: 156,
percentage: 29.5,
},
{
bucketName: 'user-uploads',
size: 1073741824,
objectCount: 3892,
percentage: 14.7,
},
{
bucketName: 'production-assets',
size: 524288000,
objectCount: 1247,
percentage: 7.2,
},
{
bucketName: 'logs',
size: 314572800,
objectCount: 8934,
percentage: 4.3,
},
],
requestMetrics: {
getRequests: 145678,
putRequests: 12456,
deleteRequests: 3421,
listRequests: 8934,
period: 'last-24h',
},
};
const mockClusterHealth = {
status: 'Healthy',
connectedNodes: 3,
knownNodes: 3,
healthyStorageNodes: 3,
declaredStorageNodes: 3,
healthyPartitions: 256,
totalPartitions: 256,
};
const mockClusterStatistics = {
timestamp: Date.now(),
uptime: 864000000,
freeform: 'Cluster operating normally',
};
const mockNodeInfo = {
nodeId: 'node-001',
version: '1.0.0',
rustVersion: '1.75.0',
uptime: 864000,
dbSize: 1073741824,
blockReferenceTableSize: 536870912,
blockMetricsTableSize: 268435456,
objectTableSize: 134217728,
objectVersionTableSize: 67108864,
bucketTableSize: 33554432,
bucketAliasTableSize: 16777216,
};
export default api;
+75
View File
@@ -0,0 +1,75 @@
// Grafana-inspired color palette for charts
export const grafanaColors = {
light: {
blue: '#1f77b4',
orange: '#ff7f0e',
green: '#2ca02c',
red: '#d62728',
purple: '#9467bd',
brown: '#8c564b',
pink: '#e377c2',
gray: '#7f7f7f',
olive: '#bcbd22',
cyan: '#17becf',
},
dark: {
blue: '#3eb0ff',
orange: '#ff9830',
green: '#73bf69',
red: '#f2495c',
purple: '#b581d8',
brown: '#c4a2e0',
pink: '#ff9d96',
gray: '#9ba3af',
olive: '#fac858',
cyan: '#37b7c3',
},
};
export const chartColorPalette = {
light: [
'#1f77b4', // Blue
'#ff7f0e', // Orange
'#2ca02c', // Green
'#d62728', // Red
'#9467bd', // Purple
'#8c564b', // Brown
'#e377c2', // Pink
'#7f7f7f', // Gray
'#bcbd22', // Olive
'#17becf', // Cyan
],
dark: [
'#3eb0ff', // Bright Blue
'#ff9830', // Orange
'#73bf69', // Green
'#f2495c', // Red
'#b581d8', // Purple
'#c4a2e0', // Pink
'#ff9d96', // Light Pink
'#9ba3af', // Gray
'#fac858', // Yellow
'#37b7c3', // Cyan
],
};
export const getChartColors = (isDark: boolean = false) => {
return isDark ? chartColorPalette.dark : chartColorPalette.light;
};
export const getTextColor = (isDark: boolean = false) => {
return isDark ? '#e0e0e0' : '#333333';
};
export const getGridColor = (isDark: boolean = false) => {
return isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
};
export const getTooltipStyle = (isDark: boolean = false) => {
return {
backgroundColor: isDark ? '#1f2937' : '#ffffff',
border: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`,
borderRadius: '6px',
color: isDark ? '#e0e0e0' : '#333333',
};
};
+34
View File
@@ -0,0 +1,34 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(d);
}
export function truncateString(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return `${str.slice(0, maxLength)}...`;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+409
View File
@@ -0,0 +1,409 @@
import { useEffect, useState } from 'react';
import { Header } from '@/components/layout/header';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { accessApi } from '@/lib/api';
import { formatDate } from '@/lib/utils';
import type { AccessKey, Permission } from '@/types';
import {
Plus,
MoreVertical,
Trash2,
Edit,
Search,
Key,
ShieldCheck,
ShieldX,
Copy,
} from 'lucide-react';
export function AccessControl() {
const [keys, setKeys] = useState<AccessKey[]>([]);
const [filteredKeys, setFilteredKeys] = useState<AccessKey[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedKey, setSelectedKey] = useState<AccessKey | null>(null);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyResource, setNewKeyResource] = useState('*');
const [newKeyActions, setNewKeyActions] = useState<string[]>(['GetObject']);
useEffect(() => {
const fetchKeys = async () => {
const data = await accessApi.listKeys();
setKeys(data);
setFilteredKeys(data);
};
fetchKeys();
}, []);
useEffect(() => {
const filtered = keys.filter(
(key) =>
key.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
key.accessKeyId.toLowerCase().includes(searchQuery.toLowerCase())
);
setFilteredKeys(filtered);
}, [searchQuery, keys]);
const handleCreateKey = async () => {
if (!newKeyName) return;
const permissions: Permission[] = [
{
resource: newKeyResource,
actions: newKeyActions,
effect: 'Allow',
},
];
await accessApi.createKey(newKeyName, permissions);
setCreateDialogOpen(false);
setNewKeyName('');
setNewKeyResource('*');
setNewKeyActions(['GetObject']);
// Refresh keys list
const data = await accessApi.listKeys();
setKeys(data);
};
const handleDeleteKey = async () => {
if (!selectedKey) return;
await accessApi.deleteKey(selectedKey.accessKeyId);
setDeleteDialogOpen(false);
setSelectedKey(null);
// Refresh keys list
const data = await accessApi.listKeys();
setKeys(data);
};
const handleToggleKeyStatus = async (key: AccessKey) => {
const newStatus = key.status === 'active' ? 'inactive' : 'active';
await accessApi.updateKey(key.accessKeyId, { status: newStatus });
// Refresh keys list
const data = await accessApi.listKeys();
setKeys(data);
};
const availableActions = [
'GetObject',
'PutObject',
'DeleteObject',
'ListBucket',
'GetBucketLocation',
'CreateBucket',
'DeleteBucket',
];
return (
<div>
<Header
title="Access Control"
/>
<div className="p-6 space-y-6">
<Tabs defaultValue="keys">
<TabsList>
<TabsTrigger value="keys">API Keys</TabsTrigger>
<TabsTrigger value="policies">Bucket Policies</TabsTrigger>
</TabsList>
<TabsContent value="keys" className="space-y-6 mt-6">
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Keys</CardTitle>
<Key className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{keys.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Keys</CardTitle>
<ShieldCheck className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{keys.filter((k) => k.status === 'active').length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Inactive Keys</CardTitle>
<ShieldX className="h-4 w-4 text-red-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{keys.filter((k) => k.status === 'inactive').length}
</div>
</CardContent>
</Card>
</div>
{/* Toolbar */}
<div className="flex items-center justify-between">
<div className="relative w-80">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search keys..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Key
</Button>
</div>
{/* Keys Table */}
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Access Key ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last Used</TableHead>
<TableHead>Permissions</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredKeys.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
{searchQuery ? 'No keys found matching your search' : 'No API keys yet'}
</TableCell>
</TableRow>
) : (
filteredKeys.map((key) => (
<TableRow key={key.accessKeyId}>
<TableCell className="font-medium">{key.name}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted px-2 py-1 rounded">
{key.accessKeyId}
</code>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => navigator.clipboard.writeText(key.accessKeyId)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</TableCell>
<TableCell>
<Badge variant={key.status === 'active' ? 'default' : 'secondary'}>
{key.status}
</Badge>
</TableCell>
<TableCell>{formatDate(key.createdAt)}</TableCell>
<TableCell>
{key.lastUsed ? formatDate(key.lastUsed) : 'Never'}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{key.permissions.slice(0, 2).map((perm, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{perm.actions.length} action{perm.actions.length > 1 ? 's' : ''}
</Badge>
))}
{key.permissions.length > 2 && (
<Badge variant="outline" className="text-xs">
+{key.permissions.length - 2} more
</Badge>
)}
</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleToggleKeyStatus(key)}>
{key.status === 'active' ? (
<>
<ShieldX className="mr-2 h-4 w-4" />
Deactivate
</>
) : (
<>
<ShieldCheck className="mr-2 h-4 w-4" />
Activate
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => {
setSelectedKey(key);
setDeleteDialogOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="policies" className="space-y-6 mt-6">
<Card>
<CardHeader>
<CardTitle>Bucket Policies</CardTitle>
<CardDescription>
Manage resource-based policies for your buckets
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-12">
Bucket policy editor coming soon...
</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
{/* Create Key Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
<DialogDescription>
Create a new API key with specific permissions
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Key Name</label>
<Input
placeholder="My Application Key"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Resource</label>
<Input
placeholder="bucket-name/* or *"
value={newKeyResource}
onChange={(e) => setNewKeyResource(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Specify which resources this key can access
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Actions</label>
<div className="grid grid-cols-2 gap-2">
{availableActions.map((action) => (
<label key={action} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={newKeyActions.includes(action)}
onChange={(e) => {
if (e.target.checked) {
setNewKeyActions([...newKeyActions, action]);
} else {
setNewKeyActions(newKeyActions.filter((a) => a !== action));
}
}}
className="rounded border-gray-300"
/>
{action}
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateKey} disabled={!newKeyName || newKeyActions.length === 0}>
Create Key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Key Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete API Key</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedKey?.name}"? Applications using this key
will lose access immediately.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteKey}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+695
View File
@@ -0,0 +1,695 @@
import { useEffect, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Header } from '@/components/layout/header';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { bucketsApi, objectsApi } from '@/lib/api';
import { formatBytes, formatDate } from '@/lib/utils';
import type { Bucket, S3Object } from '@/types';
import {
Plus,
MoreVertical,
Trash2,
Settings,
Search,
Upload,
FolderIcon,
FileIcon,
Download,
ChevronRight,
Home,
ArrowLeft,
Eye,
RotateCwIcon,
FolderPlus,
} from 'lucide-react';
export function Buckets() {
const [buckets, setBuckets] = useState<Bucket[]>([]);
const [filteredBuckets, setFilteredBuckets] = useState<Bucket[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteBucketDialogOpen, setDeleteBucketDialogOpen] = useState(false);
const [selectedBucket, setSelectedBucket] = useState<Bucket | null>(null);
const [newBucketName, setNewBucketName] = useState('');
const [showCreatePreview, setShowCreatePreview] = useState(false);
// Objects state
const [viewingBucket, setViewingBucket] = useState<string | null>(null);
const [objects, setObjects] = useState<S3Object[]>([]);
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
const [currentPath, setCurrentPath] = useState<string>('');
const [objectSearchQuery, setObjectSearchQuery] = useState('');
const [showUploadZone, setShowUploadZone] = useState(false);
const [deleteObjectDialogOpen, setDeleteObjectDialogOpen] = useState(false);
const [selectedObject, setSelectedObject] = useState<S3Object | null>(null);
const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false);
const [newDirName, setNewDirName] = useState('');
// Main area drag & drop
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: async (acceptedFiles) => {
await uploadFiles(acceptedFiles);
setShowUploadZone(false);
},
noClick: true,
});
// File upload handler
const uploadFiles = async (files: File[]) => {
if (!viewingBucket) return;
for (const file of files) {
const key = currentPath ? `${currentPath}${file.name}` : file.name;
await objectsApi.upload(viewingBucket, key, file);
}
// Refresh objects list
const data = await objectsApi.list(viewingBucket, currentPath);
setObjects(data);
};
useEffect(() => {
const fetchBuckets = async () => {
const data = await bucketsApi.list();
setBuckets(data);
setFilteredBuckets(data);
};
fetchBuckets();
}, []);
useEffect(() => {
const filtered = buckets.filter((bucket) =>
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
);
setFilteredBuckets(filtered);
}, [searchQuery, buckets]);
useEffect(() => {
if (viewingBucket) {
const fetchObjects = async () => {
const data = await objectsApi.list(viewingBucket, currentPath);
setObjects(data);
setFilteredObjects(data);
};
fetchObjects();
}
}, [viewingBucket, currentPath]);
useEffect(() => {
const filtered = objects.filter((obj) =>
obj.key.toLowerCase().includes(objectSearchQuery.toLowerCase())
);
setFilteredObjects(filtered);
}, [objectSearchQuery, objects]);
const handleCreateBucket = async () => {
if (!newBucketName) return;
await bucketsApi.create(newBucketName);
setCreateDialogOpen(false);
setNewBucketName('');
setShowCreatePreview(false);
// Refresh bucket list
const data = await bucketsApi.list();
setBuckets(data);
};
const handleDeleteBucket = async () => {
if (!selectedBucket) return;
await bucketsApi.delete(selectedBucket.name);
setDeleteBucketDialogOpen(false);
setSelectedBucket(null);
// Refresh bucket list
const data = await bucketsApi.list();
setBuckets(data);
};
const handleViewBucket = (bucketName: string) => {
setViewingBucket(bucketName);
setCurrentPath('');
setObjectSearchQuery('');
};
const handleBackToBuckets = () => {
setViewingBucket(null);
setCurrentPath('');
setObjectSearchQuery('');
};
const handleNavigateToFolder = (folderKey: string) => {
setCurrentPath(folderKey);
setObjectSearchQuery('');
};
const handleDeleteObject = async () => {
if (!selectedObject || !viewingBucket) return;
await objectsApi.delete(viewingBucket, selectedObject.key);
setDeleteObjectDialogOpen(false);
setSelectedObject(null);
// Refresh objects list
const data = await objectsApi.list(viewingBucket, currentPath);
setObjects(data);
};
const handleRefreshObjects = async () => {
if (!viewingBucket) return;
const data = await objectsApi.list(viewingBucket, currentPath);
setObjects(data);
};
const handleCreateDirectory = async () => {
if (!newDirName || !viewingBucket) return;
// Create a directory by uploading an empty object with a trailing slash
const dirKey = currentPath ? `${currentPath}${newDirName}/` : `${newDirName}/`;
await objectsApi.upload(viewingBucket, dirKey, new File([], ''));
setCreateDirDialogOpen(false);
setNewDirName('');
// Refresh objects list
const data = await objectsApi.list(viewingBucket, currentPath);
setObjects(data);
};
const getBreadcrumbs = () => {
if (!currentPath) return [{ label: 'Root', path: '' }];
const parts = currentPath.split('/').filter(Boolean);
const breadcrumbs = [{ label: 'Root', path: '' }];
parts.forEach((part, index) => {
const path = parts.slice(0, index + 1).join('/') + '/';
breadcrumbs.push({ label: part, path });
});
return breadcrumbs;
};
// If viewing a bucket's objects, show the objects view
if (viewingBucket) {
return (
<div>
<Header
title={`Objects in ${viewingBucket}`}
/>
<div className="p-6 space-y-6">
{/* Back Button */}
<Button variant="outline" onClick={handleBackToBuckets}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Buckets
</Button>
{/* Breadcrumb Navigation */}
<div className="flex items-center gap-2 text-sm">
<Home className="h-4 w-4 text-muted-foreground" />
{getBreadcrumbs().map((crumb, index) => (
<div key={index} className="flex items-center gap-2">
{index > 0 && <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<button
onClick={() => setCurrentPath(crumb.path)}
className={
index === getBreadcrumbs().length - 1
? 'font-medium'
: 'text-muted-foreground hover:text-foreground'
}
>
{crumb.label}
</button>
</div>
))}
</div>
{/* Toolbar */}
<div className="flex items-center justify-between">
<div className="relative w-80">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search objects..."
value={objectSearchQuery}
onChange={(e) => setObjectSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex items-center gap-2">
<Button onClick={() => setShowUploadZone(!showUploadZone)}>
<Upload className="mr-2 h-4 w-4" />
Upload
</Button>
<Button onClick={() => setCreateDirDialogOpen(true)}>
<FolderPlus className="mr-2 h-4 w-4" />
Create Directory
</Button>
<Button variant="outline" size="icon" onClick={handleRefreshObjects} title="Refresh">
<RotateCwIcon className="h-4 w-4" />
</Button>
</div>
</div>
{/* Upload Zone */}
{showUploadZone && (
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
<div className="flex gap-6">
{/* Upload SVG Icon */}
<div className="flex-shrink-0 flex items-center justify-center">
<div className="w-20 h-20 bg-primary/10 rounded-lg flex items-center justify-center">
<svg
className="w-12 h-12 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</div>
</div>
{/* Upload Content */}
<div className="flex-1 space-y-3">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragActive
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
}`}
>
<input {...getInputProps()} />
<p className="text-sm">
Drag and drop or{' '}
<label
htmlFor="file-folder-input"
className="font-medium text-primary hover:underline cursor-pointer"
>
select from computer
</label>
</p>
<input
id="file-folder-input"
type="file"
multiple
onChange={(e) => {
if (e.target.files) {
const files = Array.from(e.target.files);
uploadFiles(files);
setShowUploadZone(false);
}
}}
style={{ display: 'none' }}
/>
</div>
</div>
</div>
</div>
)}
{/* Objects Table with Drag & Drop */}
<div
{...getRootProps()}
className={`border rounded-lg transition-colors ${
isDragActive
? 'border-primary bg-primary/5 border-2'
: 'border-border'
}`}
>
<input {...getInputProps()} />
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Size</TableHead>
<TableHead>Last Modified</TableHead>
<TableHead>Storage Class</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredObjects.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
{objectSearchQuery
? 'No objects found matching your search'
: isDragActive
? 'Drop files or folders here'
: 'No objects in this location'}
</TableCell>
</TableRow>
) : (
filteredObjects.map((obj) => (
<TableRow key={obj.key}>
<TableCell>
<div className="flex items-center gap-2">
{obj.isFolder ? (
<FolderIcon className="h-4 w-4 text-muted-foreground" />
) : (
<FileIcon className="h-4 w-4 text-muted-foreground" />
)}
{obj.isFolder ? (
<button
onClick={() => handleNavigateToFolder(obj.key)}
className="font-medium hover:underline"
>
{obj.key.replace(currentPath, '').replace('/', '')}
</button>
) : (
<span className="font-medium">
{obj.key.replace(currentPath, '')}
</span>
)}
</div>
</TableCell>
<TableCell>{obj.isFolder ? '---' : formatBytes(obj.size)}</TableCell>
<TableCell>{formatDate(obj.lastModified)}</TableCell>
<TableCell>
{obj.storageClass && (
<Badge variant="secondary">{obj.storageClass}</Badge>
)}
</TableCell>
<TableCell>
{!obj.isFolder && (
<DropdownMenu>
<DropdownMenuTrigger>
{/*make the button not affecting the row height*/}
<Button variant="ghost" size="icon" className={"-m-3 top-1 relative"}>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => {
setSelectedObject(obj);
setDeleteObjectDialogOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
{/* Create Directory Dialog */}
<Dialog open={createDirDialogOpen} onOpenChange={setCreateDirDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Directory</DialogTitle>
<DialogDescription>
Create a new directory in {currentPath || 'the root'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Directory Name</label>
<Input
placeholder="my-directory"
value={newDirName}
onChange={(e) => setNewDirName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateDirectory();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDirDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateDirectory} disabled={!newDirName}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Object Dialog */}
<Dialog open={deleteObjectDialogOpen} onOpenChange={setDeleteObjectDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Object</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedObject?.key}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteObjectDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteObject}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// Default view: show buckets list
return (
<div>
<Header title="Buckets" />
<div className="p-6 space-y-6">
{/* Toolbar */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative w-80">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search buckets..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
</div>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Bucket
</Button>
</div>
{/* Buckets Table */}
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Region</TableHead>
<TableHead>Objects</TableHead>
<TableHead>Size</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredBuckets.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
{searchQuery ? 'No buckets found matching your search' : 'No buckets yet'}
</TableCell>
</TableRow>
) : (
filteredBuckets.map((bucket) => (
<TableRow
key={bucket.name}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleViewBucket(bucket.name)}
>
<TableCell className="font-medium">{bucket.name}</TableCell>
<TableCell>
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
</TableCell>
<TableCell>{bucket.objectCount?.toLocaleString() || 0}</TableCell>
<TableCell>{bucket.size ? formatBytes(bucket.size) : '0 B'}</TableCell>
<TableCell>{formatDate(bucket.creationDate)}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
handleViewBucket(bucket.name);
}}>
<FolderIcon className="mr-2 h-4 w-4" />
View Objects
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => e.stopPropagation()}>
<Settings className="mr-2 h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={(e) => {
e.stopPropagation();
setSelectedBucket(bucket);
setDeleteBucketDialogOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
{/* Create Bucket Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Bucket</DialogTitle>
<DialogDescription>
Create a new storage bucket for your objects
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Bucket Name</label>
<Input
placeholder="my-bucket-name"
value={newBucketName}
onChange={(e) => setNewBucketName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Must be unique and follow DNS naming conventions
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button
variant="outline"
onClick={() => setShowCreatePreview(true)}
disabled={!newBucketName}
>
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
<Button onClick={handleCreateBucket} disabled={!newBucketName}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create Bucket Preview Dialog */}
<Dialog open={showCreatePreview} onOpenChange={setShowCreatePreview}>
<DialogContent>
<DialogHeader>
<DialogTitle>Bucket Preview</DialogTitle>
<DialogDescription>
Review the bucket configuration before creating
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="border rounded-lg p-4 space-y-3">
<div>
<p className="text-xs text-muted-foreground">Bucket Name</p>
<p className="text-sm font-medium">{newBucketName}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Status</p>
<Badge variant="outline">Ready to Create</Badge>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreatePreview(false)}>
Back
</Button>
<Button onClick={handleCreateBucket}>
Confirm & Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Bucket Dialog */}
<Dialog open={deleteBucketDialogOpen} onOpenChange={setDeleteBucketDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Bucket</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedBucket?.name}"? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteBucketDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteBucket}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+285
View File
@@ -0,0 +1,285 @@
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Header } from '@/components/layout/header';
import { garageApi, analyticsApi, bucketsApi } from '@/lib/api';
import { formatBytes } from '@/lib/utils';
import type { GarageMetrics, Bucket, ClusterHealth } from '@/types';
import { Database, FolderOpen, HardDrive, Activity, Server, Zap, AlertCircle } from 'lucide-react';
import { BucketUsageChart } from '@/components/charts/BucketUsageChart';
import { RequestMetricsChart } from '@/components/charts/RequestMetricsChart';
export function Dashboard() {
const [metrics, setMetrics] = useState<GarageMetrics | null>(null);
const [buckets, setBuckets] = useState<Bucket[]>([]);
const [clusterHealth, setClusterHealth] = useState<ClusterHealth | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
const [garageMetrics, bucketsData, health] = await Promise.all([
garageApi.getFullMetrics(),
bucketsApi.list(),
garageApi.getClusterHealth(),
]);
setMetrics(garageMetrics);
setBuckets(bucketsData);
setClusterHealth(health);
setLoading(false);
};
fetchData();
}, []);
const getHealthStatus = (health: ClusterHealth | null) => {
if (!health) return { color: 'text-gray-500', label: 'Unknown', icon: AlertCircle };
if (
health.healthyStorageNodes === health.declaredStorageNodes &&
health.healthyPartitions === health.totalPartitions &&
health.connectedNodes === health.knownNodes
) {
return { color: 'text-green-500', label: 'Healthy', icon: Zap };
}
if (
health.healthyStorageNodes > 0 &&
health.healthyPartitions > 0
) {
return { color: 'text-yellow-500', label: 'Degraded', icon: AlertCircle };
}
return { color: 'text-red-500', label: 'Unhealthy', icon: AlertCircle };
};
const healthStatus = getHealthStatus(clusterHealth);
return (
<div>
<Header title="Dashboard" />
<div className="p-6 space-y-6">
{/* Top Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Storage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metrics ? formatBytes(metrics.totalSize) : '---'}
</div>
<p className="text-xs text-muted-foreground">
Across {metrics?.bucketCount || 0} buckets
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Objects</CardTitle>
<FolderOpen className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metrics?.objectCount.toLocaleString() || '---'}
</div>
<p className="text-xs text-muted-foreground">
Files and folders
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Buckets</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metrics?.bucketCount || '---'}</div>
<p className="text-xs text-muted-foreground">
Active storage buckets
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Requests (24h)</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metrics
? (
metrics.requestMetrics.getRequests +
metrics.requestMetrics.putRequests +
metrics.requestMetrics.deleteRequests +
metrics.requestMetrics.listRequests
).toLocaleString()
: '---'}
</div>
<p className="text-xs text-muted-foreground">
GET, PUT, DELETE, LIST
</p>
</CardContent>
</Card>
</div>
{/* Cluster Status */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Cluster Status</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className={`text-2xl font-bold ${healthStatus.color}`}>
{healthStatus.label}
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
{clusterHealth?.connectedNodes || 0}/{clusterHealth?.knownNodes || 0} nodes connected
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Storage Nodes</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{clusterHealth?.healthyStorageNodes || 0}/{clusterHealth?.declaredStorageNodes || 0}
</div>
<p className="text-xs text-muted-foreground">
Healthy storage nodes
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Partitions</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{clusterHealth?.healthyPartitions || 0}/{clusterHealth?.totalPartitions || 0}
</div>
<p className="text-xs text-muted-foreground">
Healthy partitions
</p>
</CardContent>
</Card>
</div>
{/* Charts Row 1 */}
<div className="grid gap-4 md:grid-cols-2">
{/* Bucket Usage Chart */}
<Card>
<CardHeader>
<CardTitle>Storage Usage by Bucket</CardTitle>
<CardDescription>Distribution of storage across buckets</CardDescription>
</CardHeader>
<CardContent>
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
<BucketUsageChart data={metrics.usageByBucket} />
) : (
<div className="text-center py-8 text-muted-foreground">No data available</div>
)}
</CardContent>
</Card>
{/* Request Metrics Chart */}
<Card>
<CardHeader>
<CardTitle>Request Metrics</CardTitle>
<CardDescription>API request distribution (24h)</CardDescription>
</CardHeader>
<CardContent>
{metrics?.requestMetrics ? (
<RequestMetricsChart data={metrics.requestMetrics} />
) : (
<div className="text-center py-8 text-muted-foreground">No data available</div>
)}
</CardContent>
</Card>
</div>
{/* Bucket Details Table */}
<Card>
<CardHeader>
<CardTitle>Storage Usage by Bucket (Table)</CardTitle>
<CardDescription>Detailed breakdown of storage across all buckets</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
metrics.usageByBucket.map((bucket) => (
<div key={bucket.bucketName} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{bucket.bucketName}</span>
<div className="flex items-center gap-4">
<span className="text-muted-foreground">
{bucket.objectCount.toLocaleString()} objects
</span>
<span className="font-medium">{formatBytes(bucket.size)}</span>
<span className="text-muted-foreground w-12 text-right">
{bucket.percentage.toFixed(1)}%
</span>
</div>
</div>
<div className="h-2 rounded-full bg-secondary overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${bucket.percentage}%` }}
/>
</div>
</div>
))
) : (
<div className="text-center py-8 text-muted-foreground">No buckets available</div>
)}
</div>
</CardContent>
</Card>
{/* Recent Buckets */}
<Card>
<CardHeader>
<CardTitle>Recent Buckets</CardTitle>
<CardDescription>Your most recently created buckets</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{buckets.slice(0, 5).map((bucket) => (
<div
key={bucket.name}
className="flex items-center justify-between py-3 border-b last:border-0"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Database className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">{bucket.name}</p>
<p className="text-sm text-muted-foreground">
Created {new Date(bucket.creationDate).toLocaleDateString()}
</p>
</div>
</div>
<div className="text-right">
<p className="font-medium">{bucket.objectCount?.toLocaleString()} objects</p>
<p className="text-sm text-muted-foreground">
{bucket.size ? formatBytes(bucket.size) : '---'}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+185
View File
@@ -0,0 +1,185 @@
// Bucket types
export interface Bucket {
name: string;
creationDate: string;
objectCount?: number;
size?: number;
region?: string;
}
export interface BucketDetails extends Bucket {
versioning?: boolean;
encryption?: boolean;
publicAccess?: boolean;
lifecycleRules?: LifecycleRule[];
}
export interface LifecycleRule {
id: string;
enabled: boolean;
prefix?: string;
expirationDays?: number;
transitions?: Transition[];
}
export interface Transition {
days: number;
storageClass: string;
}
// Object types
export interface S3Object {
key: string;
size: number;
lastModified: string;
etag?: string;
storageClass?: string;
isFolder?: boolean;
}
export interface ObjectMetadata {
key: string;
size: number;
lastModified: string;
contentType: string;
etag: string;
metadata?: Record<string, string>;
versionId?: string;
}
// Access Control types
export interface AccessKey {
accessKeyId: string;
name: string;
createdAt: string;
lastUsed?: string;
status: 'active' | 'inactive';
permissions: Permission[];
}
export interface Permission {
resource: string;
actions: string[];
effect: 'Allow' | 'Deny';
}
export interface BucketPolicy {
bucketName: string;
policy: PolicyStatement[];
}
export interface PolicyStatement {
sid?: string;
effect: 'Allow' | 'Deny';
principal: string | string[];
action: string | string[];
resource: string | string[];
condition?: Record<string, any>;
}
// User types
export interface User {
id: string;
username: string;
email: string;
role: 'admin' | 'user' | 'readonly';
createdAt: string;
}
// Storage Analytics types
export interface StorageMetrics {
totalSize: number;
objectCount: number;
bucketCount: number;
usageByBucket: BucketUsage[];
requestMetrics: RequestMetrics;
}
export interface BucketUsage {
bucketName: string;
size: number;
objectCount: number;
percentage: number;
}
export interface RequestMetrics {
getRequests: number;
putRequests: number;
deleteRequests: number;
listRequests: number;
period: string;
}
// Upload types
export interface UploadTask {
id: string;
file: File;
key: string;
bucket: string;
progress: number;
status: 'pending' | 'uploading' | 'completed' | 'error';
error?: string;
}
// API Response types
export interface ApiResponse<T> {
data?: T;
error?: string;
message?: string;
}
// Filter and Sort types
export interface TableFilter {
search?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
pageSize?: number;
page?: number;
}
// Garage Cluster types
export interface ClusterHealth {
status: string;
connectedNodes: number;
knownNodes: number;
healthyStorageNodes: number;
declaredStorageNodes: number;
healthyPartitions: number;
totalPartitions: number;
}
export interface ClusterStatistics {
timestamp: number;
uptime: number;
freeform: string;
[key: string]: any;
}
export interface NodeInfo {
nodeId: string;
version: string;
rustVersion: string;
uptime: number;
dbSize: number;
blockReferenceTableSize: number;
blockMetricsTableSize: number;
objectTableSize: number;
objectVersionTableSize: number;
bucketTableSize: number;
bucketAliasTableSize: number;
[key: string]: any;
}
export interface RequestTypeMetrics {
read: number;
write: number;
delete: number;
list: number;
}
export interface GarageMetrics extends StorageMetrics {
clusterHealth?: ClusterHealth;
clusterStatistics?: ClusterStatistics;
nodeInfo?: NodeInfo;
requestTypeMetrics?: RequestTypeMetrics;
}
+53
View File
@@ -0,0 +1,53 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})
+51
View File
@@ -0,0 +1,51 @@
module Noooste/garage-ui
go 1.25.3
require (
github.com/aws/aws-sdk-go-v2 v1.40.0
github.com/aws/aws-sdk-go-v2/credentials v1.19.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/gofiber/fiber/v3 v3.0.0-rc.3
github.com/spf13/viper v1.21.0
golang.org/x/oauth2 v0.33.0
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect
github.com/aws/smithy-go v1.23.2 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-rc.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tinylib/msgp v1.5.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.68.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.44.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
)
+114
View File
@@ -0,0 +1,114 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
github.com/aws/aws-sdk-go-v2/credentials v1.19.1 h1:JeW+EwmtTE0yXFK8SmklrFh/cGTTXsQJumgMZNlbxfM=
github.com/aws/aws-sdk-go-v2/credentials v1.19.1/go.mod h1:BOoXiStwTF+fT2XufhO0Efssbi1CNIO/ZXpZu87N0pw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0 h1:8FshVvnV2sr9kOSAbOnc/vwVmmAwMjOedKH6JW2ddPM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gofiber/fiber/v3 v3.0.0-rc.3 h1:h0KXuRHbivSslIpoHD1R/XjUsjcGwt+2vK0avFiYonA=
github.com/gofiber/fiber/v3 v3.0.0-rc.3/go.mod h1:LNBPuS/rGoUFlOyy03fXsWAeWfdGoT1QytwjRVNSVWo=
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
github.com/gofiber/utils/v2 v2.0.0-rc.2 h1:NvJTf7yMafTq16lUOJv70nr+HIOLNQcvGme/X+ftbW8=
github.com/gofiber/utils/v2 v2.0.0-rc.2/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/shamaton/msgpack/v2 v2.4.0 h1:O5Z08MRmbo0lA9o2xnQ4TXx6teJbPqEurqcCOQ8Oi/4=
github.com/shamaton/msgpack/v2 v2.4.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc=
github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+302
View File
@@ -0,0 +1,302 @@
package auth
import (
"context"
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"
"Noooste/garage-ui/internal/config"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// AuthService handles authentication operations
type AuthService struct {
config *config.AuthConfig
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oauth2Config *oauth2.Config
}
// UserInfo represents authenticated user information
type UserInfo struct {
Username string
Email string
Name string
Roles []string
}
// NewAuthService creates a new authentication service
func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) {
service := &AuthService{
config: cfg,
}
// Initialize OIDC if enabled
if cfg.Mode == "oidc" && cfg.OIDC.Enabled {
if err := service.initOIDC(); err != nil {
return nil, fmt.Errorf("failed to initialize OIDC: %w", err)
}
}
return service, nil
}
// initOIDC initializes the OIDC provider and configuration
func (a *AuthService) initOIDC() error {
ctx := context.Background()
// Create OIDC provider
provider, err := oidc.NewProvider(ctx, a.config.OIDC.IssuerURL)
if err != nil {
return fmt.Errorf("failed to create OIDC provider: %w", err)
}
a.oidcProvider = provider
// Create ID token verifier
verifierConfig := &oidc.Config{
ClientID: a.config.OIDC.ClientID,
SkipIssuerCheck: a.config.OIDC.SkipIssuerCheck,
SkipExpiryCheck: a.config.OIDC.SkipExpiryCheck,
}
a.oidcVerifier = provider.Verifier(verifierConfig)
// Create OAuth2 config
a.oauth2Config = &oauth2.Config{
ClientID: a.config.OIDC.ClientID,
ClientSecret: a.config.OIDC.ClientSecret,
Endpoint: provider.Endpoint(),
Scopes: a.config.OIDC.Scopes,
}
return nil
}
// ValidateBasicAuth validates basic authentication credentials
func (a *AuthService) ValidateBasicAuth(username, password string) bool {
// Use constant-time comparison to prevent timing attacks
usernameMatch := subtle.ConstantTimeCompare(
[]byte(username),
[]byte(a.config.Basic.Username),
) == 1
passwordMatch := subtle.ConstantTimeCompare(
[]byte(password),
[]byte(a.config.Basic.Password),
) == 1
return usernameMatch && passwordMatch
}
// ParseBasicAuth parses the Authorization header for basic auth
func ParseBasicAuth(authHeader string) (username, password string, ok bool) {
if authHeader == "" {
return "", "", false
}
// Check if it's a Basic auth header
const prefix = "Basic "
if !strings.HasPrefix(authHeader, prefix) {
return "", "", false
}
// Decode base64 credentials
encoded := authHeader[len(prefix):]
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", "", false
}
// Split username:password
credentials := string(decoded)
parts := strings.SplitN(credentials, ":", 2)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}
// GetAuthorizationURL returns the OIDC authorization URL for login
func (a *AuthService) GetAuthorizationURL(state string) (string, error) {
if a.oauth2Config == nil {
return "", fmt.Errorf("OIDC not initialized")
}
return a.oauth2Config.AuthCodeURL(state), nil
}
// ExchangeCode exchanges an authorization code for tokens
func (a *AuthService) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
if a.oauth2Config == nil {
return nil, fmt.Errorf("OIDC not initialized")
}
token, err := a.oauth2Config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
return token, nil
}
// VerifyIDToken verifies an OIDC ID token and extracts user info
func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserInfo, error) {
if a.oidcVerifier == nil {
return nil, fmt.Errorf("OIDC not initialized")
}
// Verify the ID token
idToken, err := a.oidcVerifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("failed to verify ID token: %w", err)
}
// Extract claims
var claims map[string]interface{}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to parse claims: %w", err)
}
// Extract user information using configured attributes
userInfo := &UserInfo{
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
}
// Extract roles if configured
if a.config.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
}
return userInfo, nil
}
// GetUserInfo retrieves user information from the OIDC provider
func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
if a.oidcProvider == nil {
return nil, fmt.Errorf("OIDC not initialized")
}
// Create OAuth2 token source
tokenSource := a.oauth2Config.TokenSource(ctx, token)
// Get user info from the provider
userInfoEndpoint, err := a.oidcProvider.UserInfo(ctx, tokenSource)
if err != nil {
return nil, fmt.Errorf("failed to get user info: %w", err)
}
// Extract claims
var claims map[string]interface{}
if err := userInfoEndpoint.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to parse user info claims: %w", err)
}
// Build user info
userInfo := &UserInfo{
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
}
// Extract roles if configured
if a.config.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
}
return userInfo, nil
}
// IsAdmin checks if the user has admin role
func (a *AuthService) IsAdmin(userInfo *UserInfo) bool {
if a.config.OIDC.AdminRole == "" {
return false
}
for _, role := range userInfo.Roles {
if role == a.config.OIDC.AdminRole {
return true
}
}
return false
}
// Helper functions
// extractClaim extracts a string claim from the claims map
func extractClaim(claims map[string]interface{}, key string) string {
if key == "" {
return ""
}
value, ok := claims[key]
if !ok {
return ""
}
str, ok := value.(string)
if !ok {
return ""
}
return str
}
// extractRoles extracts roles from nested claim path (e.g., "resource_access.garage-ui.roles")
func extractRoles(claims map[string]interface{}, path string) []string {
if path == "" {
return nil
}
// Split the path by dots to navigate nested claims
parts := strings.Split(path, ".")
current := claims
for i, part := range parts {
value, ok := current[part]
if !ok {
return nil
}
// If this is the last part, it should be the roles array
if i == len(parts)-1 {
return extractStringArray(value)
}
// Navigate to next level
next, ok := value.(map[string]interface{})
if !ok {
return nil
}
current = next
}
return nil
}
// extractStringArray converts an interface{} to []string if possible
func extractStringArray(value interface{}) []string {
// Try direct string array
if strArray, ok := value.([]string); ok {
return strArray
}
// Try interface array and convert to strings
if array, ok := value.([]interface{}); ok {
result := make([]string, 0, len(array))
for _, item := range array {
if str, ok := item.(string); ok {
result = append(result, str)
}
}
return result
}
return nil
}
+188
View File
@@ -0,0 +1,188 @@
package config
import (
"fmt"
"os"
"github.com/spf13/viper"
)
// Config represents the application configuration
type Config struct {
Server ServerConfig `mapstructure:"server"`
Garage GarageConfig `mapstructure:"garage"`
Auth AuthConfig `mapstructure:"auth"`
CORS CORSConfig `mapstructure:"cors"`
Logging LoggingConfig `mapstructure:"logging"`
}
// ServerConfig contains server-related configuration
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Environment string `mapstructure:"environment"`
}
// GarageConfig contains Garage S3 connection settings
type GarageConfig struct {
Endpoint string `mapstructure:"endpoint"`
Region string `mapstructure:"region"`
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
UseSSL bool `mapstructure:"use_ssl"`
ForcePathStyle bool `mapstructure:"force_path_style"`
}
// AuthConfig contains authentication configuration
type AuthConfig struct {
Mode string `mapstructure:"mode"` // "none", "basic", or "oidc"
Basic BasicAuthConfig `mapstructure:"basic"`
OIDC OIDCConfig `mapstructure:"oidc"`
}
// BasicAuthConfig contains basic authentication settings
type BasicAuthConfig struct {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
// OIDCConfig contains OIDC authentication settings
type OIDCConfig struct {
Enabled bool `mapstructure:"enabled"`
ProviderName string `mapstructure:"provider_name"`
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
Scopes []string `mapstructure:"scopes"`
IssuerURL string `mapstructure:"issuer_url"`
AuthURL string `mapstructure:"auth_url"`
TokenURL string `mapstructure:"token_url"`
UserinfoURL string `mapstructure:"userinfo_url"`
SkipIssuerCheck bool `mapstructure:"skip_issuer_check"`
SkipExpiryCheck bool `mapstructure:"skip_expiry_check"`
EmailAttribute string `mapstructure:"email_attribute"`
UsernameAttribute string `mapstructure:"username_attribute"`
NameAttribute string `mapstructure:"name_attribute"`
RoleAttributePath string `mapstructure:"role_attribute_path"`
AdminRole string `mapstructure:"admin_role"`
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
SessionMaxAge int `mapstructure:"session_max_age"`
CookieName string `mapstructure:"cookie_name"`
CookieSecure bool `mapstructure:"cookie_secure"`
CookieHTTPOnly bool `mapstructure:"cookie_http_only"`
CookieSameSite string `mapstructure:"cookie_same_site"`
}
// CORSConfig contains CORS settings for frontend communication
type CORSConfig struct {
Enabled bool `mapstructure:"enabled"`
AllowedOrigins []string `mapstructure:"allowed_origins"`
AllowedMethods []string `mapstructure:"allowed_methods"`
AllowedHeaders []string `mapstructure:"allowed_headers"`
AllowCredentials bool `mapstructure:"allow_credentials"`
MaxAge int `mapstructure:"max_age"`
}
// LoggingConfig contains logging configuration
type LoggingConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
}
// Load reads the configuration from the specified file
func Load(configPath string) (*Config, error) {
// Set default config file name if not specified
if configPath == "" {
configPath = "config.yaml"
}
// Check if config file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return nil, fmt.Errorf("config file not found: %s", configPath)
}
// Configure viper to read the config file
viper.SetConfigFile(configPath)
viper.SetConfigType("yaml")
// Allow environment variables to override config values
viper.AutomaticEnv()
// Read the configuration file
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Unmarshal the configuration into our Config struct
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Validate the configuration
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return &cfg, nil
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
// Validate server config
if c.Server.Port <= 0 || c.Server.Port > 65535 {
return fmt.Errorf("invalid server port: %d", c.Server.Port)
}
// Validate Garage config
if c.Garage.Endpoint == "" {
return fmt.Errorf("garage endpoint is required")
}
if c.Garage.AccessKey == "" {
return fmt.Errorf("garage access_key is required")
}
if c.Garage.SecretKey == "" {
return fmt.Errorf("garage secret_key is required")
}
// Validate auth mode
if c.Auth.Mode != "none" && c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" {
return fmt.Errorf("auth mode must be 'none', 'basic', or 'oidc', got: %s", c.Auth.Mode)
}
// Validate basic auth if enabled
if c.Auth.Mode == "basic" {
if c.Auth.Basic.Username == "" || c.Auth.Basic.Password == "" {
return fmt.Errorf("basic auth username and password are required when auth mode is 'basic'")
}
}
// Validate OIDC config if enabled
if c.Auth.Mode == "oidc" {
if c.Auth.OIDC.ClientID == "" {
return fmt.Errorf("oidc client_id is required when auth mode is 'oidc'")
}
if c.Auth.OIDC.IssuerURL == "" {
return fmt.Errorf("oidc issuer_url is required when auth mode is 'oidc'")
}
if len(c.Auth.OIDC.Scopes) == 0 {
return fmt.Errorf("oidc scopes are required when auth mode is 'oidc'")
}
}
return nil
}
// GetAddress returns the full server address (host:port)
func (c *Config) GetAddress() string {
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
}
// IsDevelopment returns true if running in development mode
func (c *Config) IsDevelopment() bool {
return c.Server.Environment == "development"
}
// IsProduction returns true if running in production mode
func (c *Config) IsProduction() bool {
return c.Server.Environment == "production"
}
+175
View File
@@ -0,0 +1,175 @@
package handlers
import (
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
// BucketHandler handles bucket-related operations
type BucketHandler struct {
s3Service *services.S3Service
}
// NewBucketHandler creates a new bucket handler
func NewBucketHandler(s3Service *services.S3Service) *BucketHandler {
return &BucketHandler{
s3Service: s3Service,
}
}
// ListBuckets returns all buckets
// GET /api/v1/buckets
func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
ctx := c.Context()
// List all buckets from Garage
buckets, err := h.s3Service.ListBuckets(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list buckets: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(buckets))
}
// CreateBucket creates a new bucket
// POST /api/v1/buckets
func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
ctx := c.Context()
// Parse request body
var req models.CreateBucketRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
// Validate bucket name
if req.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Check if bucket already exists
exists, err := h.s3Service.BucketExists(ctx, req.Name)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if exists {
return c.Status(fiber.StatusConflict).JSON(
models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"),
)
}
// Create the bucket
if err := h.s3Service.CreateBucket(ctx, req.Name); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()),
)
}
// Return success response
response := map[string]interface{}{
"bucket": req.Name,
"message": "Bucket created successfully",
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(response))
}
// DeleteBucket deletes a bucket
// DELETE /api/v1/buckets/:name
func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("name")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Check if bucket exists
exists, err := h.s3Service.BucketExists(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
)
}
// Delete the bucket
if err := h.s3Service.DeleteBucket(ctx, bucketName); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete bucket: "+err.Error()),
)
}
// Return success response
response := map[string]interface{}{
"bucket": bucketName,
"message": "Bucket deleted successfully",
}
return c.JSON(models.SuccessResponse(response))
}
// GetBucketInfo returns information about a specific bucket
// GET /api/v1/buckets/:name
func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("name")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Check if bucket exists
exists, err := h.s3Service.BucketExists(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
)
}
// List all buckets to find this one and get its info
buckets, err := h.s3Service.ListBuckets(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
)
}
// Find the specific bucket
for _, bucket := range buckets.Buckets {
if bucket.Name == bucketName {
return c.JSON(models.SuccessResponse(bucket))
}
}
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
)
}
+31
View File
@@ -0,0 +1,31 @@
package handlers
import (
"time"
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// HealthHandler handles health check requests
type HealthHandler struct {
version string
}
// NewHealthHandler creates a new health check handler
func NewHealthHandler(version string) *HealthHandler {
return &HealthHandler{
version: version,
}
}
// Check returns the health status of the service
func (h *HealthHandler) Check(c fiber.Ctx) error {
response := models.HealthResponse{
Status: "healthy",
Timestamp: time.Now(),
Version: h.version,
}
return c.JSON(models.SuccessResponse(response))
}
+386
View File
@@ -0,0 +1,386 @@
package handlers
import (
"io"
"time"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
// ObjectHandler handles object-related operations
type ObjectHandler struct {
s3Service *services.S3Service
}
// NewObjectHandler creates a new object handler
func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler {
return &ObjectHandler{
s3Service: s3Service,
}
}
// ListObjects returns all objects in a bucket
// GET /api/v1/buckets/:bucket/objects
func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("bucket")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Get query parameters for filtering
prefix := c.Query("prefix", "")
maxKeys := c.QueryInt("max_keys", 1000)
// Check if bucket exists
exists, err := h.s3Service.BucketExists(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
)
}
// List objects in the bucket
objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list objects: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(objects))
}
// UploadObject uploads a file to a bucket
// POST /api/v1/buckets/:bucket/objects
func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("bucket")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Check if bucket exists
exists, err := h.s3Service.BucketExists(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
)
}
// Get file from multipart form
file, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "File is required: "+err.Error()),
)
}
// Get object key (path in bucket)
key := c.FormValue("key")
if key == "" {
// Use filename as key if not provided
key = file.Filename
}
// Open the uploaded file
fileHandle, err := file.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open uploaded file: "+err.Error()),
)
}
defer fileHandle.Close()
// Get content type
contentType := file.Header.Get("Content-Type")
// Upload to Garage
uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, fileHandle, contentType)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()),
)
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
}
// GetObject downloads an object from a bucket
// GET /api/v1/buckets/:bucket/objects/:key
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Get object from Garage
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
defer body.Close()
// Set response headers
c.Set("Content-Type", objectInfo.ContentType)
c.Set("Content-Length", string(rune(objectInfo.Size)))
c.Set("ETag", objectInfo.ETag)
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
// Check if client wants to download or view inline
if c.Query("download") == "true" {
c.Set("Content-Disposition", "attachment; filename=\""+key+"\"")
}
// Stream the object body to the client
return c.SendStream(body)
}
// DeleteObject deletes an object from a bucket
// DELETE /api/v1/buckets/:bucket/objects/:key
func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Check if object exists
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"),
)
}
// Delete the object
if err := h.s3Service.DeleteObject(ctx, bucketName, key); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete object: "+err.Error()),
)
}
// Return success response
response := models.ObjectDeleteResponse{
Bucket: bucketName,
Key: key,
Deleted: true,
}
return c.JSON(models.SuccessResponse(response))
}
// GetObjectMetadata returns metadata for an object without downloading it
// HEAD /api/v1/buckets/:bucket/objects/:key
func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Get object metadata
metadata, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(metadata))
}
// GetPresignedURL generates a temporary pre-signed URL for an object
// POST /api/v1/buckets/:bucket/objects/:key/presign
func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Get expiration time from query parameter (default: 1 hour)
expiresIn := c.QueryInt("expires_in", 3600)
if expiresIn <= 0 || expiresIn > 604800 { // Max 7 days
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time (must be between 1 and 604800 seconds)"),
)
}
// Check if object exists
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"),
)
}
// Generate pre-signed URL
url, err := h.s3Service.GetPresignedURL(ctx, bucketName, key, time.Duration(expiresIn)*time.Second)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to generate pre-signed URL: "+err.Error()),
)
}
response := map[string]interface{}{
"url": url,
"expires_in": expiresIn,
"bucket": bucketName,
"key": key,
}
return c.JSON(models.SuccessResponse(response))
}
// DeleteMultipleObjects deletes multiple objects from a bucket
// DELETE /api/v1/buckets/:bucket/objects
func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameter
bucketName := c.Params("bucket")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
// Parse request body to get keys
var req struct {
Keys []string `json:"keys"`
}
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
if len(req.Keys) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key is required"),
)
}
// Delete multiple objects
if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
)
}
response := map[string]interface{}{
"bucket": bucketName,
"deleted": len(req.Keys),
"keys": req.Keys,
}
return c.JSON(models.SuccessResponse(response))
}
// UploadObjectStream uploads an object from request body stream (for large files)
// PUT /api/v1/buckets/:bucket/objects/:key
func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
// Check if bucket exists
exists, err := h.s3Service.BucketExists(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if !exists {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"),
)
}
// Get content type from header
contentType := c.Get("Content-Type", "application/octet-stream")
// Get request body as reader
bodyReader := c.Request().BodyStream()
// Upload to Garage
uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, io.NopCloser(bodyReader), contentType)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()),
)
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
}
+163
View File
@@ -0,0 +1,163 @@
package handlers
import (
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// UserHandler handles user/key management operations
// Note: Garage user management typically requires administrative API access
// This is a placeholder implementation that you'll need to extend based on
// your Garage setup and how you manage keys/users
type UserHandler struct {
// In a real implementation, you might have a Garage admin client here
// or interact with Garage's administrative API
}
// NewUserHandler creates a new user handler
func NewUserHandler() *UserHandler {
return &UserHandler{}
}
// ListUsers returns all users/keys
// GET /api/v1/users
func (h *UserHandler) ListUsers(c fiber.Ctx) error {
// NOTE: This is a placeholder implementation
// Garage manages keys/users through its administrative RPC interface
// You'll need to implement this based on your Garage setup
// For now, return a not implemented response
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeInternalError,
"User management not yet implemented. Requires Garage Admin API integration."),
)
}
// CreateUser creates a new user/key pair
// POST /api/v1/users
func (h *UserHandler) CreateUser(c fiber.Ctx) error {
// NOTE: This is a placeholder implementation
// To implement this, you need to:
// 1. Connect to Garage's admin RPC interface
// 2. Call the appropriate key creation endpoint
// 3. Return the generated access key and secret key
var req models.CreateUserRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeInternalError,
"User creation not yet implemented. Requires Garage Admin API integration."),
)
}
// DeleteUser deletes a user/key
// DELETE /api/v1/users/:access_key
func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
// NOTE: This is a placeholder implementation
accessKey := c.Params("access_key")
if accessKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
)
}
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeInternalError,
"User deletion not yet implemented. Requires Garage Admin API integration."),
)
}
// GetUser returns information about a specific user/key
// GET /api/v1/users/:access_key
func (h *UserHandler) GetUser(c fiber.Ctx) error {
accessKey := c.Params("access_key")
if accessKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
)
}
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeInternalError,
"User info not yet implemented. Requires Garage Admin API integration."),
)
}
// UpdateUserPermissions updates user permissions
// PATCH /api/v1/users/:access_key
func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error {
accessKey := c.Params("access_key")
if accessKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
)
}
var req models.UpdateUserRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeInternalError,
"User permission update not yet implemented. Requires Garage Admin API integration."),
)
}
/*
IMPLEMENTATION NOTES FOR USER MANAGEMENT:
Garage uses an administrative RPC interface for managing keys and buckets.
To implement user management, you need to:
1. Install garage-admin client or use HTTP RPC calls
2. Connect to Garage's admin port (typically 3903)
3. Implement the following operations:
- List keys: GET /v1/key
- Create key: POST /v1/key
- Get key info: GET /v1/key?id=<access_key>
- Delete key: DELETE /v1/key?id=<access_key>
- Update key: POST /v1/key?id=<access_key>
Example using HTTP client:
import (
"bytes"
"encoding/json"
"net/http"
)
type GarageAdminClient struct {
baseURL string
token string
}
func (g *GarageAdminClient) ListKeys() ([]KeyInfo, error) {
req, _ := http.NewRequest("GET", g.baseURL+"/v1/key", nil)
req.Header.Set("Authorization", "Bearer "+g.token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var keys []KeyInfo
json.NewDecoder(resp.Body).Decode(&keys)
return keys, nil
}
For more information, see:
https://garagehq.deuxfleurs.fr/documentation/reference-manual/admin-api/
*/
+146
View File
@@ -0,0 +1,146 @@
package middleware
import (
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// AuthMiddleware returns a Fiber middleware for authentication
// It handles different auth modes: none, basic, and OIDC
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler {
return func(c fiber.Ctx) error {
// If auth mode is "none", allow all requests
if cfg.Mode == "none" {
return c.Next()
}
// Handle basic authentication
if cfg.Mode == "basic" {
return handleBasicAuth(c, authService)
}
// Handle OIDC authentication
if cfg.Mode == "oidc" {
return handleOIDCAuth(c, authService, &cfg.OIDC)
}
// Unknown auth mode - deny access
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid authentication mode"),
)
}
}
// handleBasicAuth validates basic authentication credentials
func handleBasicAuth(c fiber.Ctx, authService *auth.AuthService) error {
// Get Authorization header
authHeader := c.Get("Authorization")
if authHeader == "" {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Authorization header required"),
)
}
// Parse basic auth credentials
username, password, ok := auth.ParseBasicAuth(authHeader)
if !ok {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid Authorization header format"),
)
}
// Validate credentials
if !authService.ValidateBasicAuth(username, password) {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"),
)
}
// Store username in context for later use
c.Locals("username", username)
return c.Next()
}
// handleOIDCAuth validates OIDC session/token
func handleOIDCAuth(c fiber.Ctx, authService *auth.AuthService, oidcCfg *config.OIDCConfig) error {
// Get session cookie
sessionCookie := c.Cookies(oidcCfg.CookieName)
if sessionCookie == "" {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"),
)
}
// In a production implementation, you would:
// 1. Validate the session token from the cookie
// 2. Look up the session in a session store (Redis, memory, etc.)
// 3. Verify the session hasn't expired
// 4. Extract user information from the session
//
// For now, we'll implement a basic token verification
// You should extend this based on your session management strategy
// Verify ID token if it's stored in the session
ctx := c.Context()
userInfo, err := authService.VerifyIDToken(ctx, sessionCookie)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid or expired session"),
)
}
// Store user info in context for handlers to use
c.Locals("userInfo", userInfo)
c.Locals("username", userInfo.Username)
c.Locals("email", userInfo.Email)
return c.Next()
}
// RequireAuth is a simpler middleware that just checks if auth is enabled
// Use this for routes that should only be accessible when any auth is active
func RequireAuth(cfg *config.AuthConfig) fiber.Handler {
return func(c fiber.Ctx) error {
if cfg.Mode == "none" {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"),
)
}
return c.Next()
}
}
// RequireAdmin is middleware that checks if the user has admin role (OIDC only)
func RequireAdmin(authService *auth.AuthService) fiber.Handler {
return func(c fiber.Ctx) error {
// Get user info from context (set by AuthMiddleware)
userInfoInterface := c.Locals("userInfo")
if userInfoInterface == nil {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
)
}
userInfo, ok := userInfoInterface.(*auth.UserInfo)
if !ok {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
)
}
// Check if user has admin role
if !authService.IsAdmin(userInfo) {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin role required"),
)
}
return c.Next()
}
}
+64
View File
@@ -0,0 +1,64 @@
package middleware
import (
"strings"
"Noooste/garage-ui/internal/config"
"github.com/gofiber/fiber/v3"
)
// CORSMiddleware creates a CORS middleware from configuration
func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
// If CORS is disabled, return a no-op middleware
if !cfg.Enabled {
return func(c fiber.Ctx) error {
return c.Next()
}
}
return func(c fiber.Ctx) error {
origin := c.Get("Origin")
// Check if origin is allowed
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins) {
// Set CORS headers
c.Set("Access-Control-Allow-Origin", origin)
if cfg.AllowCredentials {
c.Set("Access-Control-Allow-Credentials", "true")
}
// Set allowed methods
if len(cfg.AllowedMethods) > 0 {
c.Set("Access-Control-Allow-Methods", strings.Join(cfg.AllowedMethods, ", "))
}
// Set allowed headers
if len(cfg.AllowedHeaders) > 0 {
c.Set("Access-Control-Allow-Headers", strings.Join(cfg.AllowedHeaders, ", "))
}
// Set max age for preflight cache
if cfg.MaxAge > 0 {
c.Set("Access-Control-Max-Age", string(rune(cfg.MaxAge)))
}
}
// Handle preflight requests
if c.Method() == "OPTIONS" {
return c.SendStatus(fiber.StatusNoContent)
}
return c.Next()
}
}
// isAllowedOrigin checks if an origin is in the allowed list
func isAllowedOrigin(origin string, allowedOrigins []string) bool {
for _, allowed := range allowedOrigins {
if allowed == "*" || allowed == origin {
return true
}
}
return false
}
+56
View File
@@ -0,0 +1,56 @@
package models
// CreateBucketRequest represents a request to create a new bucket
type CreateBucketRequest struct {
Name string `json:"name" validate:"required"`
Region string `json:"region,omitempty"`
}
// DeleteBucketRequest represents a request to delete a bucket
type DeleteBucketRequest struct {
Name string `json:"name" validate:"required"`
}
// ListObjectsRequest represents a request to list objects in a bucket
type ListObjectsRequest struct {
Bucket string `json:"bucket" validate:"required"`
Prefix string `json:"prefix,omitempty"`
MaxKeys int `json:"max_keys,omitempty"`
Marker string `json:"marker,omitempty"`
}
// UploadObjectRequest represents metadata for an object upload
// Note: The actual file data comes from multipart form or request body
type UploadObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
ContentType string `json:"content_type,omitempty"`
}
// DeleteObjectRequest represents a request to delete an object
type DeleteObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
}
// GetObjectRequest represents a request to get/download an object
type GetObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
}
// CreateUserRequest represents a request to create a new user/key
type CreateUserRequest struct {
Name string `json:"name,omitempty"`
}
// DeleteUserRequest represents a request to delete a user/key
type DeleteUserRequest struct {
AccessKey string `json:"access_key" validate:"required"`
}
// UpdateUserRequest represents a request to update user permissions
type UpdateUserRequest struct {
AccessKey string `json:"access_key" validate:"required"`
Permissions []string `json:"permissions,omitempty"`
}
+126
View File
@@ -0,0 +1,126 @@
package models
import "time"
// APIResponse is the standard response structure for all API endpoints
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
}
// APIError represents an error in the API response
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// HealthResponse represents the health check response
type HealthResponse struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version"`
}
// BucketInfo represents information about a bucket
type BucketInfo struct {
Name string `json:"name"`
CreationDate time.Time `json:"creation_date"`
Region string `json:"region,omitempty"`
}
// BucketListResponse represents a list of buckets
type BucketListResponse struct {
Buckets []BucketInfo `json:"buckets"`
Count int `json:"count"`
}
// ObjectInfo represents information about an object
type ObjectInfo struct {
Key string `json:"key"`
Size int64 `json:"size"`
LastModified time.Time `json:"last_modified"`
ETag string `json:"etag"`
ContentType string `json:"content_type,omitempty"`
StorageClass string `json:"storage_class,omitempty"`
}
// ObjectListResponse represents a list of objects in a bucket
type ObjectListResponse struct {
Bucket string `json:"bucket"`
Objects []ObjectInfo `json:"objects"`
Count int `json:"count"`
IsTruncated bool `json:"is_truncated"`
NextMarker string `json:"next_marker,omitempty"`
}
// ObjectUploadResponse represents the response after uploading an object
type ObjectUploadResponse struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
ETag string `json:"etag"`
Size int64 `json:"size"`
ContentType string `json:"content_type"`
}
// ObjectDeleteResponse represents the response after deleting an object
type ObjectDeleteResponse struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
Deleted bool `json:"deleted"`
}
// UserInfo represents information about a Garage user (key pair)
type UserInfo struct {
AccessKey string `json:"access_key"`
Name string `json:"name,omitempty"`
Permissions []string `json:"permissions,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
// UserListResponse represents a list of users/keys
type UserListResponse struct {
Users []UserInfo `json:"users"`
Count int `json:"count"`
}
// Helper functions to create standard responses
// SuccessResponse creates a successful API response
func SuccessResponse(data interface{}) APIResponse {
return APIResponse{
Success: true,
Data: data,
Error: nil,
}
}
// ErrorResponse creates an error API response
func ErrorResponse(code, message string) APIResponse {
return APIResponse{
Success: false,
Data: nil,
Error: &APIError{
Code: code,
Message: message,
},
}
}
// Common error codes
const (
ErrCodeBadRequest = "BAD_REQUEST"
ErrCodeUnauthorized = "UNAUTHORIZED"
ErrCodeForbidden = "FORBIDDEN"
ErrCodeNotFound = "NOT_FOUND"
ErrCodeConflict = "CONFLICT"
ErrCodeInternalError = "INTERNAL_ERROR"
ErrCodeBucketExists = "BUCKET_ALREADY_EXISTS"
ErrCodeBucketNotFound = "BUCKET_NOT_FOUND"
ErrCodeObjectNotFound = "OBJECT_NOT_FOUND"
ErrCodeInvalidBucketName = "INVALID_BUCKET_NAME"
ErrCodeInvalidObjectKey = "INVALID_OBJECT_KEY"
ErrCodeUploadFailed = "UPLOAD_FAILED"
ErrCodeDeleteFailed = "DELETE_FAILED"
ErrCodeListFailed = "LIST_FAILED"
)
+170
View File
@@ -0,0 +1,170 @@
package routes
import (
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/middleware"
"github.com/gofiber/fiber/v3"
)
// SetupRoutes configures all API routes
func SetupRoutes(
app *fiber.App,
cfg *config.Config,
authService *auth.AuthService,
healthHandler *handlers.HealthHandler,
bucketHandler *handlers.BucketHandler,
objectHandler *handlers.ObjectHandler,
userHandler *handlers.UserHandler,
) {
// Apply CORS middleware globally
app.Use(middleware.CORSMiddleware(&cfg.CORS))
// Health check endpoint (no auth required)
app.Get("/health", healthHandler.Check)
app.Get("/api/v1/health", healthHandler.Check)
// API v1 group
api := app.Group("/api/v1")
// Apply authentication middleware to all API routes
api.Use(middleware.AuthMiddleware(&cfg.Auth, authService))
// Bucket routes
buckets := api.Group("/buckets")
{
buckets.Get("/", bucketHandler.ListBuckets) // List all buckets
buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket
buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info
buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket
}
// Object routes
objects := api.Group("/buckets/:bucket/objects")
{
objects.Get("/", objectHandler.ListObjects) // List objects in bucket
objects.Post("/", objectHandler.UploadObject) // Upload object (multipart)
objects.Delete("/", objectHandler.DeleteMultipleObjects) // Delete multiple objects
objects.Get("/:key", objectHandler.GetObject) // Download object
objects.Put("/:key", objectHandler.UploadObjectStream) // Upload object (stream)
objects.Delete("/:key", objectHandler.DeleteObject) // Delete object
objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata
objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL
}
// User/Key management routes
users := api.Group("/users")
{
users.Get("/", userHandler.ListUsers) // List all users/keys
users.Post("/", userHandler.CreateUser) // Create new user/key
users.Get("/:access_key", userHandler.GetUser) // Get user info
users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key
users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions
}
// OIDC authentication routes (only if OIDC is enabled)
if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled {
authRoutes := app.Group("/auth")
{
// Login endpoint - redirects to OIDC provider
authRoutes.Get("/login", func(c fiber.Ctx) error {
// Generate state token for CSRF protection
state := "random-state-token" // In production, use a secure random token
authURL, err := authService.GetAuthorizationURL(state)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Failed to generate login URL",
})
}
return c.Redirect().To(authURL)
})
// Callback endpoint - handles OIDC redirect after login
authRoutes.Get("/callback", func(c fiber.Ctx) error {
// Get authorization code from query
code := c.Query("code")
if code == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Authorization code is required",
})
}
// Exchange code for tokens
ctx := c.Context()
token, err := authService.ExchangeCode(ctx, code)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Failed to exchange authorization code",
})
}
// Extract ID token from OAuth2 token
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "No ID token in response",
})
}
// Verify ID token and get user info
userInfo, err := authService.VerifyIDToken(ctx, rawIDToken)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Invalid ID token",
})
}
// In production, you should:
// 1. Create a session and store it in Redis/memory
// 2. Set a secure session cookie
// 3. Redirect to the frontend with the session
// For now, just set the ID token as a cookie (not recommended for production)
c.Cookie(&fiber.Cookie{
Name: cfg.Auth.OIDC.CookieName,
Value: rawIDToken,
MaxAge: cfg.Auth.OIDC.SessionMaxAge,
Secure: cfg.Auth.OIDC.CookieSecure,
HTTPOnly: cfg.Auth.OIDC.CookieHTTPOnly,
SameSite: cfg.Auth.OIDC.CookieSameSite,
})
return c.JSON(fiber.Map{
"success": true,
"user": userInfo,
})
})
// Logout endpoint
authRoutes.Post("/logout", func(c fiber.Ctx) error {
// Clear session cookie
c.Cookie(&fiber.Cookie{
Name: cfg.Auth.OIDC.CookieName,
Value: "",
MaxAge: -1,
})
return c.JSON(fiber.Map{
"success": true,
"message": "Logged out successfully",
})
})
// User info endpoint
authRoutes.Get("/me", middleware.AuthMiddleware(&cfg.Auth, authService), func(c fiber.Ctx) error {
userInfo := c.Locals("userInfo")
if userInfo == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Not authenticated",
})
}
return c.JSON(fiber.Map{
"success": true,
"user": userInfo,
})
})
}
}
}
+312
View File
@@ -0,0 +1,312 @@
package services
import (
"context"
"fmt"
"io"
"time"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
// S3Service handles all S3 operations with Garage
type S3Service struct {
client *s3.Client
config *config.GarageConfig
}
// NewS3Service creates a new S3 service instance
func NewS3Service(cfg *config.GarageConfig) *S3Service {
// Create AWS credentials from Garage config
creds := credentials.NewStaticCredentialsProvider(
cfg.AccessKey,
cfg.SecretKey,
"", // session token (not used for Garage)
)
// Configure S3 client for Garage
s3Config := aws.Config{
Region: cfg.Region,
Credentials: creds,
}
// Create S3 client with custom endpoint resolver for Garage
client := s3.NewFromConfig(s3Config, func(o *s3.Options) {
o.BaseEndpoint = aws.String(cfg.Endpoint)
o.UsePathStyle = cfg.ForcePathStyle
})
return &S3Service{
client: client,
config: cfg,
}
}
// ListBuckets retrieves all buckets from Garage
func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) {
// Call S3 ListBuckets API
result, err := s.client.ListBuckets(ctx, &s3.ListBucketsInput{})
if err != nil {
return nil, fmt.Errorf("failed to list buckets: %w", err)
}
// Convert S3 buckets to our model
buckets := make([]models.BucketInfo, 0, len(result.Buckets))
for _, bucket := range result.Buckets {
buckets = append(buckets, models.BucketInfo{
Name: aws.ToString(bucket.Name),
CreationDate: aws.ToTime(bucket.CreationDate),
})
}
return &models.BucketListResponse{
Buckets: buckets,
Count: len(buckets),
}, nil
}
// CreateBucket creates a new bucket in Garage
func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
// Create bucket input
input := &s3.CreateBucketInput{
Bucket: aws.String(bucketName),
}
// Call S3 CreateBucket API
_, err := s.client.CreateBucket(ctx, input)
if err != nil {
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
}
return nil
}
// DeleteBucket deletes a bucket from Garage
func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
// Call S3 DeleteBucket API
_, err := s.client.DeleteBucket(ctx, &s3.DeleteBucketInput{
Bucket: aws.String(bucketName),
})
if err != nil {
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
}
return nil
}
// BucketExists checks if a bucket exists
func (s *S3Service) BucketExists(ctx context.Context, bucketName string) (bool, error) {
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{
Bucket: aws.String(bucketName),
})
if err != nil {
// Check if it's a "not found" error
return false, nil
}
return true, nil
}
// ListObjects lists objects in a bucket with optional prefix filter
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int) (*models.ObjectListResponse, error) {
// Set default max keys if not specified
if maxKeys <= 0 {
maxKeys = 1000
}
// Create list objects input
input := &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
MaxKeys: aws.Int32(int32(maxKeys)),
}
if prefix != "" {
input.Prefix = aws.String(prefix)
}
// Call S3 ListObjectsV2 API
result, err := s.client.ListObjectsV2(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
}
// Convert S3 objects to our model
objects := make([]models.ObjectInfo, 0, len(result.Contents))
for _, obj := range result.Contents {
objects = append(objects, models.ObjectInfo{
Key: aws.ToString(obj.Key),
Size: aws.ToInt64(obj.Size),
LastModified: aws.ToTime(obj.LastModified),
ETag: aws.ToString(obj.ETag),
StorageClass: string(obj.StorageClass),
})
}
return &models.ObjectListResponse{
Bucket: bucketName,
Objects: objects,
Count: len(objects),
IsTruncated: aws.ToBool(result.IsTruncated),
NextMarker: aws.ToString(result.NextContinuationToken),
}, nil
}
// UploadObject uploads an object to a bucket
func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
// Create put object input
input := &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
Body: body,
}
if contentType != "" {
input.ContentType = aws.String(contentType)
}
// Call S3 PutObject API
result, err := s.client.PutObject(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
}
// Get object metadata to return size
headResult, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
var size int64
if err == nil {
size = aws.ToInt64(headResult.ContentLength)
}
return &models.ObjectUploadResponse{
Bucket: bucketName,
Key: key,
ETag: aws.ToString(result.ETag),
Size: size,
ContentType: contentType,
}, nil
}
// GetObject retrieves an object from a bucket
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
// Call S3 GetObject API
result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
if err != nil {
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
}
// Create object info
objectInfo := &models.ObjectInfo{
Key: key,
Size: aws.ToInt64(result.ContentLength),
LastModified: aws.ToTime(result.LastModified),
ETag: aws.ToString(result.ETag),
ContentType: aws.ToString(result.ContentType),
}
return result.Body, objectInfo, nil
}
// DeleteObject deletes an object from a bucket
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
// Call S3 DeleteObject API
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
}
return nil
}
// ObjectExists checks if an object exists in a bucket
func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
if err != nil {
return false, nil
}
return true, nil
}
// GetObjectMetadata retrieves metadata for an object without downloading it
func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
result, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
if err != nil {
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
}
return &models.ObjectInfo{
Key: key,
Size: aws.ToInt64(result.ContentLength),
LastModified: aws.ToTime(result.LastModified),
ETag: aws.ToString(result.ETag),
ContentType: aws.ToString(result.ContentType),
}, nil
}
// DeleteMultipleObjects deletes multiple objects from a bucket
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
if len(keys) == 0 {
return nil
}
// Create delete objects for batch deletion
objects := make([]types.ObjectIdentifier, len(keys))
for i, key := range keys {
objects[i] = types.ObjectIdentifier{
Key: aws.String(key),
}
}
// Call S3 DeleteObjects API (batch delete)
_, err := s.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: aws.String(bucketName),
Delete: &types.Delete{
Objects: objects,
Quiet: aws.Bool(false),
},
})
if err != nil {
return fmt.Errorf("failed to delete multiple objects from bucket %s: %w", bucketName, err)
}
return nil
}
// GetPresignedURL generates a pre-signed URL for temporary access to an object
// This is useful for sharing files without exposing credentials
func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) {
// Create presign client
presignClient := s3.NewPresignClient(s.client)
// Generate presigned GET request
presignResult, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
}, func(opts *s3.PresignOptions) {
opts.Expires = expiresIn
})
if err != nil {
return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err)
}
return presignResult.URL, nil
}