| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225 |
- import { ident, safeSql } from '@supabase/pg-meta/src/pg-format'
- import { useMutation, useQueryClient } from '@tanstack/react-query'
- import { LOCAL_STORAGE_KEYS } from 'common'
- import { useEffect, useState, type ReactNode } from 'react'
- import { toast } from 'sonner'
- import { Button } from 'ui'
- import { Admonition } from 'ui-patterns/admonition'
- import { CodeBlock } from 'ui-patterns/CodeBlock'
- import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
- import { databasePoliciesKeys } from '@/data/database-policies/keys'
- import { executeSql } from '@/data/sql/execute-sql-query'
- import { storageKeys } from '@/data/storage/keys'
- import { usePublicBucketsWithSelectPoliciesQuery } from '@/data/storage/public-buckets-with-select-policies-query'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { useTrack } from '@/lib/telemetry/track'
- const DISMISS_DURATION_MS = 14 * 24 * 60 * 60 * 1000 // 14 days
- function isDismissed(projectRef: string, bucketId: string): boolean {
- try {
- const raw = localStorage.getItem(
- LOCAL_STORAGE_KEYS.STORAGE_PUBLIC_BUCKET_SELECT_POLICY_WARNING_DISMISSED(projectRef, bucketId)
- )
- if (!raw) return false
- const { dismissedAt } = JSON.parse(raw) as { dismissedAt: string }
- return Date.now() - new Date(dismissedAt).getTime() < DISMISS_DURATION_MS
- } catch {
- return false
- }
- }
- function persistDismiss(projectRef: string, bucketId: string): void {
- localStorage.setItem(
- LOCAL_STORAGE_KEYS.STORAGE_PUBLIC_BUCKET_SELECT_POLICY_WARNING_DISMISSED(projectRef, bucketId),
- JSON.stringify({ dismissedAt: new Date().toISOString() })
- )
- }
- function generatePolicyRemovalSql(policyName: string) {
- return safeSql`DROP POLICY IF EXISTS ${ident(policyName)} ON storage.objects;`
- }
- export interface PublicBucketWarningProps {
- projectRef: string
- bucketId: string
- }
- export function PublicBucketWarning({ projectRef, bucketId }: PublicBucketWarningProps): ReactNode {
- const queryClient = useQueryClient()
- const { data: project } = useSelectedProjectQuery()
- const { data } = usePublicBucketsWithSelectPoliciesQuery({
- projectRef,
- connectionString: project?.connectionString,
- bucketId,
- })
- const policyToRemove = data?.[0]
- const matchingPolicyCount = data?.length ?? 0
- const track = useTrack()
- const { mutate: removePolicy, isPending: isRemovingPolicy } = useMutation({
- mutationFn: async (policyName: string) => {
- await executeSql({
- projectRef,
- connectionString: project?.connectionString,
- sql: generatePolicyRemovalSql(policyName),
- })
- },
- onSuccess: async () => {
- await Promise.all([
- queryClient.invalidateQueries({
- queryKey: storageKeys.publicBucketsWithSelectPolicies(projectRef, bucketId),
- }),
- queryClient.invalidateQueries({
- queryKey: databasePoliciesKeys.list(projectRef, 'storage'),
- }),
- ])
- track('storage_public_bucket_select_policy_removed', { bucketId })
- setShowModal(false)
- toast.success(
- matchingPolicyCount > 1
- ? `Policy removed successfully. ${matchingPolicyCount - 1} matching ${
- matchingPolicyCount - 1 === 1 ? 'policy' : 'policies'
- } remaining.`
- : 'Policy removed successfully'
- )
- },
- onError: (error) => {
- console.error('Failed to remove policy', error)
- toast.error(`Failed to remove policy: ${error.message}`)
- },
- })
- const [showModal, setShowModal] = useState(false)
- const [dismissed, setDismissed] = useState(() => isDismissed(projectRef, bucketId))
- useEffect(() => {
- setDismissed(isDismissed(projectRef, bucketId))
- setShowModal(false)
- }, [bucketId, projectRef])
- function handleDismiss() {
- persistDismiss(projectRef, bucketId)
- track('storage_public_bucket_select_policy_warning_dismiss_button_clicked', { bucketId })
- setDismissed(true)
- }
- return policyToRemove && !dismissed ? (
- <PublicBucketWarningView
- _tag="policy-to-remove"
- policyName={policyToRemove.policyname}
- policyCount={matchingPolicyCount}
- isRemovingPolicy={isRemovingPolicy}
- onRemovePolicy={() => removePolicy(policyToRemove.policyname)}
- isModalVisible={showModal}
- onShowModal={() => setShowModal(true)}
- onHideModal={() => setShowModal(false)}
- onDismiss={handleDismiss}
- />
- ) : (
- <PublicBucketWarningView _tag="no-policy-to-remove" />
- )
- }
- type PublicBucketWarningViewProps_NoPolicyToRemove = {
- _tag: 'no-policy-to-remove'
- }
- type PublicBucketWarningViewProps_PolicyToRemove = {
- _tag: 'policy-to-remove'
- policyName: string
- policyCount: number
- isRemovingPolicy: boolean
- onRemovePolicy: () => void
- isModalVisible: boolean
- onShowModal: () => void
- onHideModal: () => void
- onDismiss: () => void
- }
- type PublicBucketWarningViewProps =
- | PublicBucketWarningViewProps_NoPolicyToRemove
- | PublicBucketWarningViewProps_PolicyToRemove
- function PublicBucketWarningView(props: PublicBucketWarningViewProps): ReactNode {
- if (props._tag === 'no-policy-to-remove') {
- return null
- }
- const {
- policyName,
- policyCount,
- isRemovingPolicy,
- onRemovePolicy,
- isModalVisible,
- onShowModal,
- onHideModal,
- onDismiss,
- } = props
- const hasMultiplePolicies = policyCount > 1
- return (
- <>
- <Admonition
- type="warning"
- layout="horizontal"
- title="Clients can list all files in this bucket"
- description={
- hasMultiplePolicies
- ? `${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.`
- : '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.'
- }
- actions={
- <div className="flex gap-2">
- <Button type="default" size="tiny" onClick={onDismiss}>
- Dismiss
- </Button>
- <Button type="warning" size="tiny" onClick={onShowModal}>
- Remove policy
- </Button>
- </div>
- }
- />
- <ConfirmationModal
- visible={isModalVisible}
- variant="destructive"
- title={
- hasMultiplePolicies
- ? `Remove SELECT policy (1 of ${policyCount})`
- : 'Remove SELECT policy'
- }
- confirmLabel="Remove policy"
- loading={isRemovingPolicy}
- onCancel={onHideModal}
- onConfirm={onRemovePolicy}
- >
- <div className="flex flex-col gap-3">
- <p className="text-sm text-foreground-light">
- This will drop {hasMultiplePolicies ? 'one' : 'the'}{' '}
- <code className="text-code-inline">SELECT</code>
- {
- ' policy that makes the bucket’s contents listable. Object URLs will continue to work.'
- }
- {hasMultiplePolicies
- ? ` ${policyCount - 1} matching ${
- policyCount - 1 === 1 ? 'policy' : 'policies'
- } will remain after this.`
- : null}
- </p>
- <div className="-mx-4 md:-mx-5 -mb-4 border-t">
- <CodeBlock
- hideLineNumbers
- language="sql"
- value={generatePolicyRemovalSql(policyName)}
- wrapperClassName="[&_pre]:px-4 [&_pre]:py-3 [&>pre]:rounded-none [&>pre]:border-0 [&_pre>*]:whitespace-pre-wrap!"
- className="[&_code]:text-foreground"
- />
- </div>
- </div>
- </ConfirmationModal>
- </>
- )
- }
|