Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: implemented client side rate limiting #1063

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 185 additions & 125 deletions src/app/harbor/shipyard/new-ship-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ async function getReadmeFromRepo(url: string) {
return (await testReadmeLink(readmeURI)) ? readmeURI : null
}

// rate limit params
const maxSubmissions = 5
const rateLimitWindow = 3600 * 1000

function getSubmissions() {
const submissions = localStorage.getItem('shipSubmissions')
return submissions ? JSON.parse(submissions) : []
}

function saveSubmissions(submissions: number[]) {
localStorage.setItem('shipSubmissions', JSON.stringify(submissions))
}

export default function NewShipForm({
ships,
canvasRef,
Expand All @@ -49,7 +62,11 @@ export default function NewShipForm({
canvasRef: any
closeForm: any
session: any
timeout: number
}) {
const [rateLimitExceeded, setRateLimitExceeded] = useState<boolean>(false)
const [timeRemaining, setTimeRemaining] = useState<number>(0)
const timeoutId = useRef<NodeJS.Timeout | null>(null)
const [staging, setStaging] = useState(false)
const confettiRef = useRef<JSConfetti | null>(null)
const [usedRepos, setUsedRepos] = useState<string[]>([])
Expand Down Expand Up @@ -91,6 +108,21 @@ export default function NewShipForm({
{ label: 'Asylum', value: 'asylum' },
]

useEffect(() => {
if (rateLimitExceeded && timeRemaining > 0) {
const timeout = setTimeout(() => {
setRateLimitExceeded(false)
setTimeRemaining(0)
}, timeRemaining)
timeoutId.current = timeout as NodeJS.Timeout
return () => {
if (timeoutId.current !== null) {
clearTimeout(timeoutId.current)
}
}
}
}, [rateLimitExceeded, timeRemaining])

// Initialize confetti on mount
useEffect(() => {
confettiRef.current = new JSConfetti({ canvas: canvasRef.current })
Expand Down Expand Up @@ -122,146 +154,168 @@ export default function NewShipForm({
}, [ships])

const handleForm = async (formData: FormData) => {
setStaging(true)
const submissions = getSubmissions()
const now = Date.now()
const filteredSubmissions = submissions.filter(
(s: number) => now - s < rateLimitWindow,
)

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
if (selectedProjects === null || selectedProjects?.length === 0) {
if (filteredSubmissions.length >= maxSubmissions) {
setRateLimitExceeded(true)
const oldest = filteredSubmissions[0]
const timeRemaining = rateLimitWindow - (now - oldest)
setTimeRemaining(timeRemaining)
toast({
title: 'Select a project',
description: 'Please select at least one Hackatime project!',
title: 'Rate Limit Exceeded',
description: `You have reached your submission limit. Try again in ${Math.ceil(timeRemaining / 1000)} seconds.`,
})
setStaging(false)
return
}
} else {
const newSubmissions = [...filteredSubmissions, now]
saveSubmissions(newSubmissions)

const deploymentUrl = formData.get('deployment_url') as string
if (
['github.com', 'gitlab.com', 'bitbucket.org'].some((domain) =>
deploymentUrl.includes(domain),
)
) {
toast({
title: "That's not a demo link!",
description:
'Submit a link to a deployed project or a video demo of what your project is instead!',
})
setStaging(false)
return
}
// continuing with submission
setStaging(true)

if (deploymentUrl.includes('drive.google')) {
toast({
title: "Drive links aren't allowed",
description:
"Drive links aren't allowed. Link to a deployed project directly, or if you can't upload your video somewhere else",
})
setStaging(false)
return
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
if (selectedProjects === null || selectedProjects?.length === 0) {
toast({
title: 'Select a project',
description: 'Please select at least one Hackatime project!',
})
setStaging(false)
return
}

const repoUrl = formData.get('repo_url') as string
if (usedRepos.includes(repoUrl)) {
toast({
title: 'You already submitted a project from this repo!',
description:
"If you're shipping an update to a project, use the 'ship an update' button instead.",
})
}
const deploymentUrl = formData.get('deployment_url') as string
if (
['github.com', 'gitlab.com', 'bitbucket.org'].some((domain) =>
deploymentUrl.includes(domain),
)
) {
toast({
title: "That's not a demo link!",
description:
'Submit a link to a deployed project or a video demo of what your project is instead!',
})
setStaging(false)
return
}

const screenshotUrl = formData.get('screenshot_url') as string
const readmeUrl = formData.get('readme_url') as string
const [screenshotRes, readmeRes] = await Promise.all([
fetch(screenshotUrl).catch((e) => console.error(e)),
fetch(readmeUrl).catch((e) => console.error(e)),
])
if (!screenshotRes) {
toast({
title: "We couldn't load your screenshot link!",
description: 'Try #cdn instead!',
})
setStaging(false)
return
}
if (!screenshotRes?.headers?.get('content-type')?.startsWith('image')) {
toast({
title: "That's not an image!",
description: 'Submit a link to an image of your project instead!',
})
setStaging(false)
return
}
if (deploymentUrl.includes('drive.google')) {
toast({
title: "Drive links aren't allowed",
description:
"Drive links aren't allowed. Link to a deployed project directly, or if you can't upload your video somewhere else",
})
setStaging(false)
return
}

if (screenshotUrl.includes('cdn.discordapp.com')) {
toast({
title: "That screenshot doesn't work!",
description:
'Discord links are temporary, please host your files in #cdn!',
})
setStaging(false)
return
}
const repoUrl = formData.get('repo_url') as string
if (usedRepos.includes(repoUrl)) {
toast({
title: 'You already submitted a project from this repo!',
description:
"If you're shipping an update to a project, use the 'ship an update' button instead.",
})
}

if (!screenshotUrl.startsWith('https://')) {
toast({
title: "That screenshot doesn't work!",
description:
'Please use http or https links (no data urls), please host your files in #cdn!',
})
setStaging(false)
return
}
if (
deploymentUrl.includes('localhost') ||
deploymentUrl.includes('127.0.0.1')
) {
toast({
title: "That's not a demo link!",
description:
'Please make sure your link isnt a local link.. Please submit a deployed link instead!',
})
setStaging(false)
return
}
const screenshotUrl = formData.get('screenshot_url') as string
const readmeUrl = formData.get('readme_url') as string
const [screenshotRes, readmeRes] = await Promise.all([
fetch(screenshotUrl).catch((e) => console.error(e)),
fetch(readmeUrl).catch((e) => console.error(e)),
])
if (!screenshotRes) {
toast({
title: "We couldn't load your screenshot link!",
description: 'Try #cdn instead!',
})
setStaging(false)
return
}
if (!screenshotRes?.headers?.get('content-type')?.startsWith('image')) {
toast({
title: "That's not an image!",
description: 'Submit a link to an image of your project instead!',
})
setStaging(false)
return
}

if (readmeUrl.includes('github.com')) {
toast({
title: "This isn't a markdown link!",
description:
'Submit a link to the raw README file in your repo instead!',
})
setStaging(false)
return
}
if (screenshotUrl.includes('cdn.discordapp.com')) {
toast({
title: "That screenshot doesn't work!",
description:
'Discord links are temporary, please host your files in #cdn!',
})
setStaging(false)
return
}

if (
readmeRes.status !== 200 ||
!['text/plain', 'text/markdown'].includes(
readmeRes?.headers?.get('content-type')?.split(';')[0] || '',
)
) {
toast({
title: "That's not a valid README link!",
description: 'Submit a link to a README file in your repo instead!',
})
setStaging(false)
return
}
if (!screenshotUrl.startsWith('https://')) {
toast({
title: "That screenshot doesn't work!",
description:
'Please use http or https links (no data urls), please host your files in #cdn!',
})
setStaging(false)
return
}
if (
deploymentUrl.includes('localhost') ||
deploymentUrl.includes('127.0.0.1')
) {
toast({
title: "That's not a demo link!",
description:
'Please make sure your link isnt a local link.. Please submit a deployed link instead!',
})
setStaging(false)
return
}

formData.append('yswsType', yswsType)
if (readmeUrl.includes('github.com')) {
toast({
title: "This isn't a markdown link!",
description:
'Submit a link to the raw README file in your repo instead!',
})
setStaging(false)
return
}

if (
readmeRes.status !== 200 ||
!['text/plain', 'text/markdown'].includes(
readmeRes?.headers?.get('content-type')?.split(';')[0] || '',
)
) {
toast({
title: "That's not a valid README link!",
description: 'Submit a link to a README file in your repo instead!',
})
setStaging(false)
return
}

formData.append('yswsType', yswsType)

const isTutorial = sessionStorage?.getItem('tutorial') === 'true'
confettiRef.current?.addConfetti()
closeForm()
if (isTutorial) {
const isTutorial = sessionStorage?.getItem('tutorial') === 'true'
confettiRef.current?.addConfetti()
closeForm()
if (isTutorial) {
window.location.reload()
} else {
const _newShip = await createShip(formData, false)
setStaging(false)
}

// ideally we don't have to reload the page here.
window.location.reload()
} else {
const _newShip = await createShip(formData, false)
setStaging(false)
}

// ideally we don't have to reload the page here.
window.location.reload()
}

const projectDropdownList = projects?.map((p: any) => ({
Expand Down Expand Up @@ -464,12 +518,18 @@ export default function NewShipForm({
</div>
)}

<Button type="submit" disabled={staging} id="new-ship-submit">
<Button
type="submit"
disabled={staging || rateLimitExceeded}
id="new-ship-submit"
>
{staging ? (
<>
<Icon glyph="more" />
Staging!
</>
) : rateLimitExceeded ? (
`Try again in ${Math.ceil(timeRemaining / 1000)}s`
) : (
'Submit as a draft'
)}
Expand Down
Loading