Skip to content

Commit 68a47f1

Browse files
authored
fix: handle readonly bucket states safely (#188)
1 parent 1f21323 commit 68a47f1

12 files changed

Lines changed: 162 additions & 81 deletions

File tree

‎app/(dashboard)/browser/content.tsx‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useTasks } from "@/contexts/task-context"
1818
import { ObjectPreviewModal } from "@/components/object/preview-modal"
1919
import { useObject } from "@/hooks/use-object"
2020
import { usePermissions } from "@/hooks/use-permissions"
21+
import { isMissingBucketError } from "@/lib/bucket-access"
2122

2223
interface BrowserContentProps {
2324
bucketName: string
@@ -51,15 +52,9 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
5152
headBucket(bucketName)
5253
.then(() => {})
5354
.catch((error: unknown) => {
54-
const err = error as { $metadata?: { httpStatusCode?: number }; Code?: string; message?: string }
55-
const status = err?.$metadata?.httpStatusCode
56-
const code = (err?.Code ?? (error as Error)?.message ?? "").toLowerCase()
57-
const isAccessDenied =
58-
status === 403 ||
59-
code === "accessdenied" ||
60-
code === "forbidden" ||
61-
(typeof code === "string" && (code.includes("access denied") || code.includes("forbidden")))
62-
message.error(isAccessDenied ? t("Access Denied") : t("Bucket not found"))
55+
if (!isMissingBucketError(error)) return
56+
57+
message.error(t("Bucket not found"))
6358
const params = new URLSearchParams(searchParams.toString())
6459
params.delete("bucket")
6560
params.delete("prefix")

‎app/(dashboard)/browser/page.tsx‎

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { useMessage } from "@/lib/feedback/message"
2121
import { formatDateTime, formatInteger, niceBytes } from "@/lib/functions"
2222
import { normalizeDateToIso } from "@/lib/safe-date"
2323
import { getAccountBucketUsage } from "@/lib/account-bucket-usage"
24+
import { loadBucketPolicyStatuses } from "@/lib/bucket-policy-status"
2425
import { BrowserContent } from "./content"
2526
import type { ColumnDef } from "@tanstack/react-table"
2627

@@ -58,26 +59,13 @@ function BrowserBucketsPage() {
5859
}
5960

6061
try {
61-
const results = await Promise.all(
62-
bucketNames.map(async (name) => {
63-
try {
64-
const resp = (await getBucketPolicyStatus(name)) as {
65-
PolicyStatus?: { IsPublic?: boolean }
66-
}
67-
return { name, isPublic: resp?.PolicyStatus?.IsPublic === true }
68-
} catch {
69-
return { name, isPublic: false }
70-
}
71-
}),
72-
)
62+
const policyMap = await loadBucketPolicyStatuses(bucketNames, getBucketPolicyStatus)
7363
if (fetchId !== fetchIdRef.current) return
7464

75-
const policyMap = Object.fromEntries(results.map((r) => [r.name, r.isPublic]))
76-
7765
setData((prev) =>
7866
prev.map((row) => ({
7967
...row,
80-
IsPublic: policyMap[row.Name] ?? false,
68+
IsPublic: policyMap[row.Name],
8169
})),
8270
)
8371
} catch {
@@ -226,7 +214,7 @@ function BrowserBucketsPage() {
226214
<span className="text-muted-foreground">{t("Private")}</span>
227215
)
228216
}
229-
return policyLoading ? <Spinner className="size-3 text-muted-foreground" /> : "--"
217+
return policyLoading ? <Spinner className="size-3 text-muted-foreground" /> : "-"
230218
},
231219
})
232220

‎components/buckets/list.tsx‎

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Spinner } from "@/components/ui/spinner"
1414
import { formatDateTime, formatInteger, niceBytes } from "@/lib/functions"
1515
import { normalizeDateToIso } from "@/lib/safe-date"
1616
import { getAccountBucketUsage } from "@/lib/account-bucket-usage"
17+
import { loadBucketPolicyStatuses } from "@/lib/bucket-policy-status"
1718
import type { ColumnDef } from "@tanstack/react-table"
1819

1920
export interface BucketListRow {
@@ -50,25 +51,13 @@ export function BucketList({ title, emptyDescription, getBucketHref }: BucketLis
5051
}
5152

5253
try {
53-
const results = await Promise.all(
54-
bucketNames.map(async (name) => {
55-
try {
56-
const resp = (await getBucketPolicyStatus(name)) as {
57-
PolicyStatus?: { IsPublic?: boolean }
58-
}
59-
return { name, isPublic: resp?.PolicyStatus?.IsPublic === true }
60-
} catch {
61-
return { name, isPublic: false }
62-
}
63-
}),
64-
)
54+
const policyMap = await loadBucketPolicyStatuses(bucketNames, getBucketPolicyStatus)
6555
if (fetchId !== fetchIdRef.current) return
6656

67-
const policyMap = Object.fromEntries(results.map((r) => [r.name, r.isPublic]))
6857
setData((prev) =>
6958
prev.map((row) => ({
7059
...row,
71-
IsPublic: policyMap[row.Name] ?? false,
60+
IsPublic: policyMap[row.Name],
7261
})),
7362
)
7463
} finally {
@@ -202,7 +191,7 @@ export function BucketList({ title, emptyDescription, getBucketHref }: BucketLis
202191
<span className="text-muted-foreground">{t("Private")}</span>
203192
)
204193
}
205-
return policyLoading ? <Spinner className="size-3 text-muted-foreground" /> : "--"
194+
return policyLoading ? <Spinner className="size-3 text-muted-foreground" /> : "-"
206195
},
207196
},
208197
],

‎components/data-table/data-table.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { cn } from "@/lib/utils"
1212
interface DataTableProps<TData> {
1313
table: Table<TData>
1414
isLoading?: boolean
15+
errorTitle?: string
16+
errorDescription?: string
1517
emptyTitle?: string
1618
emptyDescription?: string
1719
caption?: string
@@ -67,6 +69,8 @@ function getAriaSort<TData>(column: Column<TData, unknown>): React.AriaAttribute
6769
export function DataTable<TData>({
6870
table,
6971
isLoading = false,
72+
errorTitle,
73+
errorDescription,
7074
emptyTitle = "No data",
7175
emptyDescription = "There is nothing to display yet.",
7276
caption,
@@ -132,6 +136,14 @@ export function DataTable<TData>({
132136
</div>
133137
</TableCell>
134138
</TableRow>
139+
) : errorTitle ? (
140+
<TableRow>
141+
<TableCell colSpan={visibleColumnCount} className="h-48">
142+
<div role="alert">
143+
<EmptyState title={errorTitle} description={errorDescription} />
144+
</div>
145+
</TableCell>
146+
</TableRow>
135147
) : hasRows ? (
136148
table.getRowModel().rows.map((row) => (
137149
<TableRow

‎components/object/list.tsx‎

Lines changed: 37 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,21 @@ interface ObjectRow {
8787
LastModified: string
8888
}
8989

90+
type ObjectListError = "accessDenied" | "loadFailed"
91+
92+
function isAccessDeniedError(error: unknown): boolean {
93+
const serviceError = error as {
94+
$metadata?: { httpStatusCode?: number }
95+
Code?: string
96+
name?: string
97+
message?: string
98+
}
99+
if (serviceError?.$metadata?.httpStatusCode === 403) return true
100+
101+
const code = (serviceError?.Code ?? serviceError?.name ?? serviceError?.message ?? "").toLowerCase()
102+
return code === "accessdenied" || code === "forbidden" || code.includes("access denied")
103+
}
104+
90105
interface ObjectListProps {
91106
bucket: string
92107
path: string
@@ -123,12 +138,11 @@ export function ObjectList({
123138
const [searchTerm, setSearchTerm] = React.useState("")
124139
const [showDeleted, setShowDeleted] = useLocalStorage("object-list-show-deleted", false)
125140
const [loading, setLoading] = React.useState(false)
141+
const [listError, setListError] = React.useState<ObjectListError | null>(null)
126142
const [data, setData] = React.useState<ObjectRow[]>([])
127143
const [nextToken, setNextToken] = React.useState<string | undefined>()
128144
const [showScrollShortcuts, setShowScrollShortcuts] = React.useState(false)
129145
const [bucketVersioningState, setBucketVersioningState] = React.useState<BucketVersioningState>("unknown")
130-
const [versioningError, setVersioningError] = React.useState("")
131-
const [versioningReload, setVersioningReload] = React.useState(0)
132146
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
133147
const [deleteDialogKeys, setDeleteDialogKeys] = React.useState<string[]>([])
134148
const [deleteAllVersions, setDeleteAllVersions] = React.useState(false)
@@ -203,6 +217,7 @@ export function ObjectList({
203217
const requestScope = activeScopeRef.current
204218
loadingRef.current = true
205219
setLoading(true)
220+
if (!shouldAppend) setListError(null)
206221
try {
207222
const response = await listObject(bucket, prefix || undefined, resolvedPageSize, token, {
208223
includeDeleted: showDeleted,
@@ -226,6 +241,7 @@ export function ObjectList({
226241
}
227242

228243
setNextToken(r.NextContinuationToken)
244+
setListError(null)
229245

230246
const prefixItems: ObjectRow[] = (r.CommonPrefixes ?? []).map((item) => ({
231247
Key: item.Prefix ?? "",
@@ -261,6 +277,7 @@ export function ObjectList({
261277
setNextToken(undefined)
262278
if (!shouldAppend) {
263279
setData([])
280+
setListError(isAccessDeniedError(error) ? "accessDenied" : "loadFailed")
264281
}
265282
}
266283
} finally {
@@ -327,7 +344,6 @@ export function ObjectList({
327344

328345
const loadBucketVersioningStatus = async () => {
329346
setBucketVersioningState("unknown")
330-
setVersioningError("")
331347

332348
try {
333349
const resp = await getBucketVersioning(bucket)
@@ -338,7 +354,6 @@ export function ObjectList({
338354
console.error("Failed to load bucket versioning status:", error)
339355
if (!cancelled) {
340356
setBucketVersioningState("unknown")
341-
setVersioningError(t("Failed to get data"))
342357
}
343358
}
344359
}
@@ -348,7 +363,7 @@ export function ObjectList({
348363
return () => {
349364
cancelled = true
350365
}
351-
}, [bucket, getBucketVersioning, t, versioningReload])
366+
}, [bucket, getBucketVersioning])
352367

353368
const displayKey = React.useCallback(
354369
(key: string) => {
@@ -531,7 +546,6 @@ export function ObjectList({
531546
size="sm"
532547
onClick={() => openDeleteDialog([row.original.Key])}
533548
disabled={bucketVersioningState === "unknown"}
534-
aria-describedby={bucketVersioningState === "unknown" ? "object-versioning-status" : undefined}
535549
>
536550
<RiDeleteBin5Line className="size-4" aria-hidden />
537551
<span>{t("Delete")}</span>
@@ -781,7 +795,6 @@ export function ObjectList({
781795
className="border-destructive text-destructive"
782796
onClick={handleBatchDelete}
783797
disabled={bucketVersioningState === "unknown"}
784-
aria-describedby={bucketVersioningState === "unknown" ? "object-versioning-status" : undefined}
785798
>
786799
<RiDeleteBin5Line className="size-4" aria-hidden />
787800
<span>{t("Delete Selected")}</span>
@@ -824,44 +837,30 @@ export function ObjectList({
824837
</div>
825838
</PageHeader>
826839

827-
{bucketVersioningState === "unknown" ? (
828-
<div
829-
id="object-versioning-status"
830-
role={versioningError ? "alert" : "status"}
831-
className="flex flex-col gap-3 border border-border bg-muted/30 p-3 text-sm sm:flex-row sm:items-center sm:justify-between"
832-
>
833-
<span className={versioningError ? "text-destructive" : "text-muted-foreground"}>
834-
{versioningError || t("Loading…")}
835-
</span>
836-
{versioningError ? (
837-
<Button
838-
type="button"
839-
variant="outline"
840-
className="w-full sm:w-auto"
841-
onClick={() => setVersioningReload((value) => value + 1)}
842-
>
843-
<RiRefreshLine className="size-4" aria-hidden />
844-
{t("Refresh")}
845-
</Button>
846-
) : null}
847-
</div>
848-
) : null}
849-
850840
<DataTable
851841
table={table}
852842
isLoading={displayState === "loading" || displayState === "filtered-loading"}
843+
errorTitle={
844+
listError === "accessDenied"
845+
? t("Access Denied")
846+
: listError === "loadFailed"
847+
? t("Failed to load objects")
848+
: undefined
849+
}
853850
emptyTitle={emptyTitle}
854851
emptyDescription={emptyDescription}
855852
/>
856853

857-
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
858-
<span>
859-
{t("Loaded {count} objects", {
860-
count: data.length,
861-
})}
862-
</span>
863-
<span>{t("Filtering and sorting apply to loaded objects")}</span>
864-
</div>
854+
{!listError ? (
855+
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
856+
<span>
857+
{t("Loaded {count} objects", {
858+
count: data.length,
859+
})}
860+
</span>
861+
<span>{t("Filtering and sorting apply to loaded objects")}</span>
862+
</div>
863+
) : null}
865864

866865
{nextToken ? (
867866
<div ref={loadMoreRef} className="flex min-h-10 items-center justify-center text-sm text-muted-foreground">

‎lib/bucket-access.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
interface S3ServiceError {
2+
$metadata?: {
3+
httpStatusCode?: number
4+
}
5+
Code?: string
6+
name?: string
7+
message?: string
8+
}
9+
10+
export function isMissingBucketError(error: unknown): boolean {
11+
const serviceError = error as S3ServiceError
12+
if (serviceError?.$metadata?.httpStatusCode === 404) return true
13+
14+
const code = (serviceError?.Code ?? serviceError?.name ?? "").toLowerCase()
15+
return code === "nosuchbucket" || code === "notfound"
16+
}

‎lib/bucket-policy-status.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export interface BucketPolicyStatusResponse {
2+
PolicyStatus?: {
3+
IsPublic?: boolean
4+
}
5+
}
6+
7+
export async function loadBucketPolicyStatuses(
8+
bucketNames: string[],
9+
getBucketPolicyStatus: (bucketName: string) => Promise<unknown>,
10+
): Promise<Record<string, boolean | undefined>> {
11+
const results = await Promise.all(
12+
bucketNames.map(async (name) => {
13+
try {
14+
const response = (await getBucketPolicyStatus(name)) as BucketPolicyStatusResponse
15+
return [name, response.PolicyStatus?.IsPublic] as const
16+
} catch {
17+
return [name, undefined] as const
18+
}
19+
}),
20+
)
21+
22+
return Object.fromEntries(results)
23+
}

‎tests/lib/bucket-access.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import test from "node:test"
2+
import assert from "node:assert/strict"
3+
4+
import { isMissingBucketError } from "../../lib/bucket-access"
5+
6+
test("isMissingBucketError redirects only for a definitive missing bucket response", () => {
7+
assert.equal(isMissingBucketError({ $metadata: { httpStatusCode: 404 } }), true)
8+
assert.equal(isMissingBucketError({ Code: "NoSuchBucket" }), true)
9+
})
10+
11+
test("isMissingBucketError does not treat access denied as a missing bucket", () => {
12+
assert.equal(isMissingBucketError({ $metadata: { httpStatusCode: 403 }, Code: "AccessDenied" }), false)
13+
})
14+
15+
test("isMissingBucketError keeps the current bucket when availability is uncertain", () => {
16+
assert.equal(isMissingBucketError(new Error("Network request failed")), false)
17+
})

0 commit comments

Comments
 (0)