PublicBucketWarning.tsx225 lines · main
| 1 | import { ident, safeSql } from '@supabase/pg-meta/src/pg-format' |
| 2 | import { useMutation, useQueryClient } from '@tanstack/react-query' |
| 3 | import { LOCAL_STORAGE_KEYS } from 'common' |
| 4 | import { useEffect, useState, type ReactNode } from 'react' |
| 5 | import { toast } from 'sonner' |
| 6 | import { Button } from 'ui' |
| 7 | import { Admonition } from 'ui-patterns/admonition' |
| 8 | import { CodeBlock } from 'ui-patterns/CodeBlock' |
| 9 | import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' |
| 10 | |
| 11 | import { databasePoliciesKeys } from '@/data/database-policies/keys' |
| 12 | import { executeSql } from '@/data/sql/execute-sql-query' |
| 13 | import { storageKeys } from '@/data/storage/keys' |
| 14 | import { usePublicBucketsWithSelectPoliciesQuery } from '@/data/storage/public-buckets-with-select-policies-query' |
| 15 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 16 | import { useTrack } from '@/lib/telemetry/track' |
| 17 | |
| 18 | const DISMISS_DURATION_MS = 14 * 24 * 60 * 60 * 1000 // 14 days |
| 19 | |
| 20 | function isDismissed(projectRef: string, bucketId: string): boolean { |
| 21 | try { |
| 22 | const raw = localStorage.getItem( |
| 23 | LOCAL_STORAGE_KEYS.STORAGE_PUBLIC_BUCKET_SELECT_POLICY_WARNING_DISMISSED(projectRef, bucketId) |
| 24 | ) |
| 25 | if (!raw) return false |
| 26 | const { dismissedAt } = JSON.parse(raw) as { dismissedAt: string } |
| 27 | return Date.now() - new Date(dismissedAt).getTime() < DISMISS_DURATION_MS |
| 28 | } catch { |
| 29 | return false |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | function persistDismiss(projectRef: string, bucketId: string): void { |
| 34 | localStorage.setItem( |
| 35 | LOCAL_STORAGE_KEYS.STORAGE_PUBLIC_BUCKET_SELECT_POLICY_WARNING_DISMISSED(projectRef, bucketId), |
| 36 | JSON.stringify({ dismissedAt: new Date().toISOString() }) |
| 37 | ) |
| 38 | } |
| 39 | |
| 40 | function generatePolicyRemovalSql(policyName: string) { |
| 41 | return safeSql`DROP POLICY IF EXISTS ${ident(policyName)} ON storage.objects;` |
| 42 | } |
| 43 | |
| 44 | export interface PublicBucketWarningProps { |
| 45 | projectRef: string |
| 46 | bucketId: string |
| 47 | } |
| 48 | |
| 49 | export function PublicBucketWarning({ projectRef, bucketId }: PublicBucketWarningProps): ReactNode { |
| 50 | const queryClient = useQueryClient() |
| 51 | const { data: project } = useSelectedProjectQuery() |
| 52 | |
| 53 | const { data } = usePublicBucketsWithSelectPoliciesQuery({ |
| 54 | projectRef, |
| 55 | connectionString: project?.connectionString, |
| 56 | bucketId, |
| 57 | }) |
| 58 | const policyToRemove = data?.[0] |
| 59 | const matchingPolicyCount = data?.length ?? 0 |
| 60 | |
| 61 | const track = useTrack() |
| 62 | |
| 63 | const { mutate: removePolicy, isPending: isRemovingPolicy } = useMutation({ |
| 64 | mutationFn: async (policyName: string) => { |
| 65 | await executeSql({ |
| 66 | projectRef, |
| 67 | connectionString: project?.connectionString, |
| 68 | sql: generatePolicyRemovalSql(policyName), |
| 69 | }) |
| 70 | }, |
| 71 | onSuccess: async () => { |
| 72 | await Promise.all([ |
| 73 | queryClient.invalidateQueries({ |
| 74 | queryKey: storageKeys.publicBucketsWithSelectPolicies(projectRef, bucketId), |
| 75 | }), |
| 76 | queryClient.invalidateQueries({ |
| 77 | queryKey: databasePoliciesKeys.list(projectRef, 'storage'), |
| 78 | }), |
| 79 | ]) |
| 80 | track('storage_public_bucket_select_policy_removed', { bucketId }) |
| 81 | setShowModal(false) |
| 82 | toast.success( |
| 83 | matchingPolicyCount > 1 |
| 84 | ? `Policy removed successfully. ${matchingPolicyCount - 1} matching ${ |
| 85 | matchingPolicyCount - 1 === 1 ? 'policy' : 'policies' |
| 86 | } remaining.` |
| 87 | : 'Policy removed successfully' |
| 88 | ) |
| 89 | }, |
| 90 | onError: (error) => { |
| 91 | console.error('Failed to remove policy', error) |
| 92 | toast.error(`Failed to remove policy: ${error.message}`) |
| 93 | }, |
| 94 | }) |
| 95 | |
| 96 | const [showModal, setShowModal] = useState(false) |
| 97 | const [dismissed, setDismissed] = useState(() => isDismissed(projectRef, bucketId)) |
| 98 | |
| 99 | useEffect(() => { |
| 100 | setDismissed(isDismissed(projectRef, bucketId)) |
| 101 | setShowModal(false) |
| 102 | }, [bucketId, projectRef]) |
| 103 | |
| 104 | function handleDismiss() { |
| 105 | persistDismiss(projectRef, bucketId) |
| 106 | track('storage_public_bucket_select_policy_warning_dismiss_button_clicked', { bucketId }) |
| 107 | setDismissed(true) |
| 108 | } |
| 109 | |
| 110 | return policyToRemove && !dismissed ? ( |
| 111 | <PublicBucketWarningView |
| 112 | _tag="policy-to-remove" |
| 113 | policyName={policyToRemove.policyname} |
| 114 | policyCount={matchingPolicyCount} |
| 115 | isRemovingPolicy={isRemovingPolicy} |
| 116 | onRemovePolicy={() => removePolicy(policyToRemove.policyname)} |
| 117 | isModalVisible={showModal} |
| 118 | onShowModal={() => setShowModal(true)} |
| 119 | onHideModal={() => setShowModal(false)} |
| 120 | onDismiss={handleDismiss} |
| 121 | /> |
| 122 | ) : ( |
| 123 | <PublicBucketWarningView _tag="no-policy-to-remove" /> |
| 124 | ) |
| 125 | } |
| 126 | |
| 127 | type PublicBucketWarningViewProps_NoPolicyToRemove = { |
| 128 | _tag: 'no-policy-to-remove' |
| 129 | } |
| 130 | |
| 131 | type PublicBucketWarningViewProps_PolicyToRemove = { |
| 132 | _tag: 'policy-to-remove' |
| 133 | policyName: string |
| 134 | policyCount: number |
| 135 | isRemovingPolicy: boolean |
| 136 | onRemovePolicy: () => void |
| 137 | isModalVisible: boolean |
| 138 | onShowModal: () => void |
| 139 | onHideModal: () => void |
| 140 | onDismiss: () => void |
| 141 | } |
| 142 | |
| 143 | type PublicBucketWarningViewProps = |
| 144 | | PublicBucketWarningViewProps_NoPolicyToRemove |
| 145 | | PublicBucketWarningViewProps_PolicyToRemove |
| 146 | |
| 147 | function PublicBucketWarningView(props: PublicBucketWarningViewProps): ReactNode { |
| 148 | if (props._tag === 'no-policy-to-remove') { |
| 149 | return null |
| 150 | } |
| 151 | |
| 152 | const { |
| 153 | policyName, |
| 154 | policyCount, |
| 155 | isRemovingPolicy, |
| 156 | onRemovePolicy, |
| 157 | isModalVisible, |
| 158 | onShowModal, |
| 159 | onHideModal, |
| 160 | onDismiss, |
| 161 | } = props |
| 162 | const hasMultiplePolicies = policyCount > 1 |
| 163 | |
| 164 | return ( |
| 165 | <> |
| 166 | <Admonition |
| 167 | type="warning" |
| 168 | layout="horizontal" |
| 169 | title="Clients can list all files in this bucket" |
| 170 | description={ |
| 171 | hasMultiplePolicies |
| 172 | ? `${policyCount} broad SELECT policies on storage.objects allow clients to retrieve a full list of files. Public buckets don’t need these policies and they may expose more data than intended.` |
| 173 | : 'A broad SELECT policy on storage.objects allows clients to retrieve a full list of files. Public buckets don’t need this and it may expose more data than intended.' |
| 174 | } |
| 175 | actions={ |
| 176 | <div className="flex gap-2"> |
| 177 | <Button type="default" size="tiny" onClick={onDismiss}> |
| 178 | Dismiss |
| 179 | </Button> |
| 180 | <Button type="warning" size="tiny" onClick={onShowModal}> |
| 181 | Remove policy |
| 182 | </Button> |
| 183 | </div> |
| 184 | } |
| 185 | /> |
| 186 | <ConfirmationModal |
| 187 | visible={isModalVisible} |
| 188 | variant="destructive" |
| 189 | title={ |
| 190 | hasMultiplePolicies |
| 191 | ? `Remove SELECT policy (1 of ${policyCount})` |
| 192 | : 'Remove SELECT policy' |
| 193 | } |
| 194 | confirmLabel="Remove policy" |
| 195 | loading={isRemovingPolicy} |
| 196 | onCancel={onHideModal} |
| 197 | onConfirm={onRemovePolicy} |
| 198 | > |
| 199 | <div className="flex flex-col gap-3"> |
| 200 | <p className="text-sm text-foreground-light"> |
| 201 | This will drop {hasMultiplePolicies ? 'one' : 'the'}{' '} |
| 202 | <code className="text-code-inline">SELECT</code> |
| 203 | { |
| 204 | ' policy that makes the bucket’s contents listable. Object URLs will continue to work.' |
| 205 | } |
| 206 | {hasMultiplePolicies |
| 207 | ? ` ${policyCount - 1} matching ${ |
| 208 | policyCount - 1 === 1 ? 'policy' : 'policies' |
| 209 | } will remain after this.` |
| 210 | : null} |
| 211 | </p> |
| 212 | <div className="-mx-4 md:-mx-5 -mb-4 border-t"> |
| 213 | <CodeBlock |
| 214 | hideLineNumbers |
| 215 | language="sql" |
| 216 | value={generatePolicyRemovalSql(policyName)} |
| 217 | wrapperClassName="[&_pre]:px-4 [&_pre]:py-3 [&>pre]:rounded-none [&>pre]:border-0 [&_pre>*]:whitespace-pre-wrap!" |
| 218 | className="[&_code]:text-foreground" |
| 219 | /> |
| 220 | </div> |
| 221 | </div> |
| 222 | </ConfirmationModal> |
| 223 | </> |
| 224 | ) |
| 225 | } |