mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Chore/spam page (#29854)
* Add spam page * Add select * Add experimental flag in next.config to see if it resolves the build issue * Add slack webhook call * updated endpoint * revert * update * fix placeholder styling in Select * clean * Update [ref].tsx * Update select.tsx * revert * protect form and endpoint from bad ref abuse * Delete spam api route * Add api route back * Basic api route * Rename * rename route * Add as route handler * Stub in supabase update * Add the proper supabase project * Rename spam to opt-out * Swap anon for service role key * Rename env var --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com> Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
This commit is contained in:
5 files changed
+271
-2
No files matched your search
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_EMAIL_ABUSE_URL as string
|
||||
const supabaseServiceKey = process.env.EMAIL_ABUSE_SERVICE_KEY as string
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { ref: string } }) {
|
||||
const ref = params.ref
|
||||
const { reason, email } = await req.json()
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
if (!ref) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bad Request: Missing or invalid project reference.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const refPattern = /^[a-zA-Z]{20}$/
|
||||
const refIsInvalid = !refPattern.test(ref)
|
||||
|
||||
if (refIsInvalid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bad Request: Missing or invalid project reference.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: supabaseError } = await supabase
|
||||
.from('manual_reports')
|
||||
.insert([{ project_ref: ref, reason, email }])
|
||||
|
||||
if (supabaseError) throw new Error(`Supabase error: ${supabaseError.message}`)
|
||||
|
||||
const response = await fetch(process.env.EMAIL_REPORT_SLACK_WEBHOOK as string, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: `New report from: ${ref} \n\n ${reason}` }),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to send to Slack')
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Thank you! We have received your report.' },
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message
|
||||
return NextResponse.json(
|
||||
{ error: `Failure: Could not send post to Slack. Error: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import Layout from 'components/Layouts/Default'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Form_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormMessage_Shadcn_,
|
||||
Select_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
SelectGroup_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
SelectValue_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { z } from 'zod'
|
||||
|
||||
const formSchema = z.object({
|
||||
ref: z.string(),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export default function OptOutPage() {
|
||||
const router = useRouter()
|
||||
const ref = router.query.ref as string
|
||||
const email = router.query.email as string | undefined
|
||||
|
||||
const [formMessage, setFormMessage] = useState<string | null>(null)
|
||||
const [submissionType, setSubmissionType] = useState<'success' | 'error' | null>(null)
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
ref: ref,
|
||||
reason: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const { register } = form
|
||||
|
||||
// Ensure ref is a string
|
||||
const refString = typeof ref !== 'string'
|
||||
const refPattern = /^[a-zA-Z]{20}$/
|
||||
const refIsInvalid = !refString && !refPattern.test(ref)
|
||||
const SelectItemContainerClasses = 'flex flex-col text-sm items-start'
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof formSchema>) => {
|
||||
try {
|
||||
setSubmissionType(null)
|
||||
setFormMessage('Submitting...')
|
||||
|
||||
const response = await fetch(`/api-v2/opt-out/${data.ref}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ reason: data.reason, email: email }),
|
||||
})
|
||||
const message = await response.text()
|
||||
|
||||
if (refIsInvalid) {
|
||||
setFormMessage('Error: Invalid project reference.')
|
||||
setSubmissionType('error')
|
||||
} else if (response.ok) {
|
||||
setFormMessage(message)
|
||||
setSubmissionType('success')
|
||||
} else {
|
||||
console.error('Form submission failed:', message)
|
||||
setFormMessage('Error: Form submission failed.')
|
||||
setSubmissionType('error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting form:', error)
|
||||
setFormMessage('Error: An unexpected error occurred.')
|
||||
setSubmissionType('error')
|
||||
}
|
||||
}
|
||||
|
||||
function OptOutLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Supabase | Opt Out" description="Opt out of receiving emails." />
|
||||
<Layout className="overflow-visible mx-auto max-w-xl mt-24 flex flex-col gap-8">
|
||||
{children}
|
||||
</Layout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ ref: ref, reason: '' })
|
||||
}, [ref])
|
||||
|
||||
return (
|
||||
<>
|
||||
<OptOutLayout>
|
||||
<div>
|
||||
<span className="label">Opt out</span>
|
||||
<h2 className="h4 !m-0">Receive an unexpected email from Supabase?</h2>
|
||||
<p className="text-foreground-light">Please report it here so we can investigate.</p>
|
||||
</div>
|
||||
|
||||
{refIsInvalid && (
|
||||
<Admonition type="warning" title="Invalid project reference">
|
||||
<p>The project reference you entered is invalid.</p>
|
||||
<p>Please check the URL and try again.</p>
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="gap-4 max-w-xs flex flex-col">
|
||||
<input hidden value={form.getValues('ref')} readOnly {...register('ref')} />
|
||||
<FormField_Shadcn_
|
||||
name="reason"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Choose reason for reporting" labelOptional="Optional">
|
||||
<Select_Shadcn_ onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<SelectTrigger_Shadcn_ size="medium">
|
||||
<SelectValue_Shadcn_ placeholder="Select a reason (optional)">
|
||||
{field.value}
|
||||
</SelectValue_Shadcn_>
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="phishing">
|
||||
<div className={SelectItemContainerClasses}>
|
||||
<span>Phishing</span>
|
||||
<span className="text-foreground-light text-xs">
|
||||
Attempt to obtain sensitive information
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="advertisement">
|
||||
<div className={SelectItemContainerClasses}>
|
||||
<span>Advertisement</span>
|
||||
<span className="text-foreground-light text-xs">
|
||||
Unwanted promotional content
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="malware">
|
||||
<div className={SelectItemContainerClasses}>
|
||||
<span>Malware</span>
|
||||
<span className="text-foreground-light text-xs">
|
||||
Contains harmful software
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="scam">
|
||||
<div className={SelectItemContainerClasses}>
|
||||
<span>Scam</span>
|
||||
<span className="text-foreground-light text-xs">
|
||||
Fraudulent or deceptive content
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="other">
|
||||
<div className={SelectItemContainerClasses}>
|
||||
<span>Other</span>
|
||||
<span className="text-foreground-light text-xs">
|
||||
Any other type of spam
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem_Shadcn_>
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<Button htmlType="submit" size="small" disabled={submissionType === 'success'}>
|
||||
Report spam
|
||||
</Button>
|
||||
<FormMessage_Shadcn_
|
||||
className={cn(
|
||||
'border-l pl-3',
|
||||
submissionType === 'error'
|
||||
? 'border-destructive'
|
||||
: 'border-foreground-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
{formMessage}
|
||||
</FormMessage_Shadcn_>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</OptOutLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const OptOutIndex = () => {
|
||||
const router = useRouter()
|
||||
|
||||
// We don't want anyone landing here directly
|
||||
useEffect(() => {
|
||||
router.push('/')
|
||||
}, [router])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default OptOutIndex
|
||||
@@ -47,7 +47,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-md border border-strong hover:border-stronger bg-alternative dark:bg-muted hover:bg-selection text-xs ring-offset-background-control placeholder:text-foreground-muted focus:outline-none ring-border-control focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200',
|
||||
'flex w-full items-center justify-between rounded-md border border-strong hover:border-stronger bg-alternative dark:bg-muted hover:bg-selection text-xs ring-offset-background-control data-[placeholder]:text-foreground-lighter focus:outline-none ring-border-control focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200',
|
||||
'data-[state=open]:bg-selection data-[state=open]:border-stronger',
|
||||
SelectTriggerVariants({ size }),
|
||||
className
|
||||
|
||||
+3
-1
@@ -70,7 +70,9 @@
|
||||
"LIVE_SUPABASE_COM_SERVICE_ROLE_KEY",
|
||||
"VERCEL_URL",
|
||||
"HUBSPOT_PORTAL_ID",
|
||||
"HUBSPOT_ENTERPRISE_FORM_GUID"
|
||||
"HUBSPOT_ENTERPRISE_FORM_GUID",
|
||||
"EMAIL_REPORT_SLACK_WEBHOOK",
|
||||
"EMAIL_ABUSE_SERVICE_KEY"
|
||||
],
|
||||
"outputs": [".next/**", "!.next/cache/**", ".contentlayer/**"]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user