diff --git a/backend/internal/handlers/buckets.go b/backend/internal/handlers/buckets.go index b9f42e5..3947a71 100644 --- a/backend/internal/handlers/buckets.go +++ b/backend/internal/handlers/buckets.go @@ -67,11 +67,13 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error { } bucketInfo := models.BucketInfo{ - Name: bucketName, - CreationDate: adminBucket.Created, - Region: "", // Garage doesn't have regions - ObjectCount: &detailedInfo.Objects, - Size: &detailedInfo.Bytes, + Name: bucketName, + CreationDate: adminBucket.Created, + Region: "", + ObjectCount: &detailedInfo.Objects, + Size: &detailedInfo.Bytes, + WebsiteAccess: detailedInfo.WebsiteAccess, + WebsiteConfig: detailedInfo.WebsiteConfig, } buckets = append(buckets, bucketInfo) @@ -306,3 +308,77 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error { return c.JSON(models.SuccessResponse(result)) } + +// UpdateBucketWebsite updates the website access configuration for a bucket +// +// @Summary Update bucket website configuration +// @Description Enables or disables static website hosting for a bucket +// @Tags Buckets +// @Accept json +// @Produce json +// @Param name path string true "Name of the bucket" +// @Param request body models.UpdateBucketWebsiteRequest true "Website configuration" +// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Website configuration updated" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update bucket" +// @Router /api/v1/buckets/{name}/website [put] +func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) error { + ctx := c.Context() + + bucketName := c.Params("name") + if bucketName == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"), + ) + } + + var req models.UpdateBucketWebsiteRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()), + ) + } + + if req.Enabled && req.IndexDocument == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "indexDocument is required when enabling website access"), + ) + } + + bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()), + ) + } + + if bucketInfo == nil { + return c.Status(fiber.StatusNotFound).JSON( + models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"), + ) + } + + websiteAccess := &models.UpdateBucketWebsiteAccess{ + Enabled: req.Enabled, + } + if req.Enabled { + websiteAccess.IndexDocument = &req.IndexDocument + if req.ErrorDocument != "" { + websiteAccess.ErrorDocument = &req.ErrorDocument + } + } + + updateReq := models.UpdateBucketRequest{ + WebsiteAccess: websiteAccess, + } + + result, err := h.adminService.UpdateBucket(ctx, bucketInfo.ID, updateReq) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to update bucket website: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(result)) +} diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go index ed876a2..87f8eb5 100644 --- a/backend/internal/models/requests.go +++ b/backend/internal/models/requests.go @@ -59,3 +59,10 @@ type UpdateUserRequest struct { Status *string `json:"status,omitempty"` // "active" or "inactive" Expiration *string `json:"expiration,omitempty"` // ISO 8601 date string } + +// UpdateBucketWebsiteRequest represents a request to update bucket website configuration +type UpdateBucketWebsiteRequest struct { + Enabled bool `json:"enabled"` + IndexDocument string `json:"indexDocument,omitempty"` + ErrorDocument string `json:"errorDocument,omitempty"` +} diff --git a/backend/internal/models/responses.go b/backend/internal/models/responses.go index 8ded350..aac5851 100644 --- a/backend/internal/models/responses.go +++ b/backend/internal/models/responses.go @@ -40,11 +40,13 @@ type HealthResponse struct { // BucketInfo represents information about a bucket type BucketInfo struct { - Name string `json:"name"` - CreationDate time.Time `json:"creationDate"` - ObjectCount *int64 `json:"objectCount,omitempty"` - Size *int64 `json:"size,omitempty"` - Region string `json:"region,omitempty"` + Name string `json:"name"` + CreationDate time.Time `json:"creationDate"` + ObjectCount *int64 `json:"objectCount,omitempty"` + Size *int64 `json:"size,omitempty"` + Region string `json:"region,omitempty"` + WebsiteAccess bool `json:"websiteAccess"` + WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"` } // BucketListResponse represents a list of buckets diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index 68910e2..b798ed5 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -60,6 +60,7 @@ func SetupRoutes( buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions + buckets.Put("/:name/website", bucketHandler.UpdateBucketWebsite) // Update bucket website configuration } // Object routes diff --git a/frontend/src/components/buckets/BucketListView.tsx b/frontend/src/components/buckets/BucketListView.tsx index cd610dd..3216425 100644 --- a/frontend/src/components/buckets/BucketListView.tsx +++ b/frontend/src/components/buckets/BucketListView.tsx @@ -9,7 +9,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react'; +import { FolderIcon, Globe, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react'; import { formatBytes } from '@/lib/file-utils'; import { formatDate } from '@/lib/utils'; import type { Bucket } from '@/types'; @@ -23,6 +23,7 @@ interface BucketListViewProps { onOpenSettings: (bucket: Bucket) => void; onCreateBucket: () => void; onDeleteBucket: (bucket: Bucket) => void; + onWebsiteSettings: (bucket: Bucket) => void; } export function BucketListView({ @@ -34,6 +35,7 @@ export function BucketListView({ onOpenSettings, onCreateBucket, onDeleteBucket, + onWebsiteSettings, }: BucketListViewProps) { const filteredBuckets = buckets.filter((bucket) => bucket.name.toLowerCase().includes(searchQuery.toLowerCase()) @@ -94,7 +96,15 @@ export function BucketListView({ className="cursor-pointer hover:bg-muted/50" onClick={() => onViewBucket(bucket.name)} > - {bucket.name} + + {bucket.name} + {bucket.websiteAccess && ( + + + Website + + )} + {bucket.region || 'default'} @@ -123,6 +133,13 @@ export function BucketListView({ Settings + { + e.stopPropagation(); + onWebsiteSettings(bucket); + }}> + + Website Settings + void; + bucket: Bucket | null; + onSave: ( + bucketName: string, + payload: { enabled: boolean; indexDocument?: string; errorDocument?: string } + ) => Promise; +} + +export function BucketWebsiteDialog({ + open, + onOpenChange, + bucket, + onSave, +}: BucketWebsiteDialogProps) { + const [enabled, setEnabled] = useState(false); + const [indexDocument, setIndexDocument] = useState('index.html'); + const [errorDocument, setErrorDocument] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open && bucket) { + setEnabled(bucket.websiteAccess); + setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html'); + setErrorDocument(bucket.websiteConfig?.errorDocument ?? ''); + } + }, [open, bucket]); + + const handleSave = async () => { + if (!bucket) return; + setSaving(true); + const success = await onSave(bucket.name, { + enabled, + indexDocument: enabled ? indexDocument : undefined, + errorDocument: enabled && errorDocument ? errorDocument : undefined, + }); + setSaving(false); + if (success) onOpenChange(false); + }; + + return ( + + + + Website Hosting — {bucket?.name} + + Configure this bucket to serve a static website. + + + +
+
+
+

Website access

+

+ Allow public HTTP access to bucket objects +

+
+
+ + {enabled ? 'Enabled' : 'Disabled'} + + +
+
+ + {enabled && ( +
+
+ + setIndexDocument(e.target.value)} + placeholder="index.html" + /> +

+ The file served when a directory is requested (e.g. index.html) +

+
+ +
+ + setErrorDocument(e.target.value)} + placeholder="404.html (optional)" + /> +

+ The file served when an object is not found (optional) +

+
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..1ef7f1f --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface SwitchProps extends Omit, 'onChange'> { + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; +} + +const Switch = React.forwardRef( + ({ className, checked, onCheckedChange, ...props }, ref) => { + return ( + + ); + } +); +Switch.displayName = 'Switch'; + +export { Switch }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bbf3940..b755bf1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,6 +2,7 @@ import axios from 'axios'; import {toast} from 'sonner'; import type { AccessKey, + ApiResponse, Bucket, BucketDetails, ClusterHealth, @@ -200,6 +201,17 @@ export const bucketsApi = { updateSettings: async (name: string, settings: Partial): Promise => { await api.patch(`/v1/buckets/${name}/settings`, settings); }, + + updateBucketWebsite: async ( + name: string, + payload: { enabled: boolean; indexDocument?: string; errorDocument?: string } + ) => { + const response = await api.put>( + `/v1/buckets/${encodeURIComponent(name)}/website`, + payload + ); + return response.data.data; + }, }; // Objects API diff --git a/frontend/src/pages/Buckets.tsx b/frontend/src/pages/Buckets.tsx index cf4a679..9e66dba 100644 --- a/frontend/src/pages/Buckets.tsx +++ b/frontend/src/pages/Buckets.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useSearchParams } from 'react-router-dom'; import { Header } from '@/components/layout/header'; import { useBuckets, useCreateBucket, useDeleteBucket, useGrantBucketPermission } from '@/hooks/useApi'; @@ -8,8 +9,10 @@ import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView'; import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog'; import { DeleteBucketDialog } from '@/components/buckets/DeleteBucketDialog'; import { BucketSettingsDialog } from '@/components/buckets/BucketSettingsDialog'; +import { BucketWebsiteDialog } from '@/components/buckets/BucketWebsiteDialog'; import type { Bucket } from '@/types'; import { toast } from 'sonner'; +import { bucketsApi } from '@/lib/api'; export function Buckets() { const [searchParams, setSearchParams] = useSearchParams(); @@ -21,6 +24,8 @@ export function Buckets() { const [selectedBucket, setSelectedBucket] = useState(null); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); const [settingsBucket, setSettingsBucket] = useState(null); + const [websiteDialogOpen, setWebsiteDialogOpen] = useState(false); + const [websiteBucket, setWebsiteBucket] = useState(null); // Object browser state - initialize from URL params const [viewingBucket, setViewingBucket] = useState(searchParams.get('bucket')); @@ -51,6 +56,7 @@ export function Buckets() { }, [searchParams]); // Custom hooks + const queryClient = useQueryClient(); const { data: buckets = [], isLoading: bucketsLoading } = useBuckets(); const createBucketMutation = useCreateBucket(); const deleteBucketMutation = useDeleteBucket(); @@ -139,6 +145,25 @@ export function Buckets() { setSettingsDialogOpen(true); }; + const handleOpenWebsiteSettings = (bucket: Bucket) => { + setWebsiteBucket(bucket); + setWebsiteDialogOpen(true); + }; + + const handleSaveWebsite = async ( + bucketName: string, + payload: { enabled: boolean; indexDocument?: string; errorDocument?: string } + ): Promise => { + try { + await bucketsApi.updateBucketWebsite(bucketName, payload); + queryClient.invalidateQueries({ queryKey: ['buckets'] }); + toast.success('Website configuration updated'); + return true; + } catch { + return false; + } + }; + const handleRefreshObjects = async () => { if (isRefreshing) return; try { @@ -224,6 +249,7 @@ export function Buckets() { onSearchChange={setSearchQuery} onViewBucket={handleViewBucket} onOpenSettings={handleOpenSettings} + onWebsiteSettings={handleOpenWebsiteSettings} onCreateBucket={() => setCreateDialogOpen(true)} onDeleteBucket={(bucket) => { setSelectedBucket(bucket); @@ -252,6 +278,13 @@ export function Buckets() { bucket={settingsBucket} onGrantPermission={grantPermission} /> + + ); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7f1023c..dcad9ed 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -5,6 +5,11 @@ export interface Bucket { objectCount?: number; size?: number; region?: string; + websiteAccess: boolean; + websiteConfig?: { + indexDocument: string; + errorDocument?: string; + }; } export interface BucketDetails extends Bucket {