fix!: disallow shouts via form submit

This commit is contained in:
alina 🌸 2024-09-23 15:24:34 +03:00
parent f7063a355a
commit 53321427ce
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
3 changed files with 27 additions and 31 deletions

View file

@ -148,7 +148,6 @@ export async function createShout(params: {
fromIp: string
private: boolean
text: string
isFormSubmit: boolean
}): Promise<boolean | string> {
let { text } = params
@ -157,7 +156,7 @@ export async function createShout(params: {
const validateResult = validateShout(text, !params.private)
const header = html`${params.private ? 'private message' : 'shout'} from <code>${params.fromIp}</code>`
const subheader = html`<br>via: ${params.isFormSubmit ? '#form' : '#api'}<br><br>`
const subheader = html`<br>via: #api<br><br>`
if (params.private || validateResult !== true) {
const was = params.private ? '' : ` was auto-declined (${validateResult})`

View file

@ -1,6 +1,6 @@
/* eslint-disable no-alert */
/** @jsxImportSource solid-js */
import { type ComponentProps, Show, createSignal } from 'solid-js'
import { type ComponentProps, Show, createSignal, onMount } from 'solid-js'
import { QueryClient, QueryClientProvider, createQuery, keepPreviousData } from '@tanstack/solid-query'
import { format } from 'date-fns/format'
@ -42,6 +42,8 @@ function ShoutboxInner(props: {
initialData: initData,
}))
const [sending, setSending] = createSignal(false)
const [jsEnabled, setJsEnabled] = createSignal(false)
onMount(() => setJsEnabled(true))
const onPageClick = (next: boolean) => (e: MouseEvent) => {
e.preventDefault()
@ -89,14 +91,15 @@ function ShoutboxInner(props: {
)
})
let form!: HTMLFormElement
let privateCheckbox!: HTMLInputElement
let messageInput!: HTMLTextAreaElement
const onSubmit = (e: Event) => {
e.preventDefault()
setSending(true)
setInitData(undefined)
const isPrivate = (form.elements.namedItem('private') as HTMLInputElement).checked
const isPrivate = privateCheckbox.checked
fetch('/api/shoutbox', {
method: 'POST',
headers: {
@ -104,8 +107,8 @@ function ShoutboxInner(props: {
},
body: JSON.stringify({
_csrf: props.csrf,
message: (form.elements.namedItem('message') as HTMLInputElement).value,
private: isPrivate ? '' : undefined,
message: messageInput.value,
private: isPrivate,
}),
})
.then(res => res.json())
@ -114,17 +117,23 @@ function ShoutboxInner(props: {
alert(data.error + (data.message ? `: ${data.message}` : ''))
} else if (isPrivate) {
alert('private message sent')
form.reset()
messageInput.value = ''
} else {
alert('shout sent! it will appear after moderation')
shouts.refetch()
form.reset()
messageInput.value = ''
}
setSending(false)
})
}
const placeholder = () => {
if (props.shoutError) return props.shoutError
if (!jsEnabled()) return '⚠️ please enable javascript to use the form.\nim sorry, but there are just too many spammers out there :c'
return 'let the void hear you'
}
return (
<section>
@ -137,22 +146,24 @@ function ShoutboxInner(props: {
pre-moderated, but they do not reflect my&nbsp;views.
</TextComment>
<form action="/api/shoutbox" class={css.form} method="post" ref={form}>
<div class={css.form}>
<input type="hidden" name="_csrf" value={props.csrf} />
<div class={css.formInput}>
<TextArea
disabled={sending()}
ref={messageInput}
disabled={sending() || !jsEnabled()}
class={css.textarea}
grow
maxRows={5}
name="message"
placeholder={props.shoutError || 'let the void hear you'}
placeholder={placeholder()}
required
/>
<Button
type="submit"
onClick={onSubmit}
disabled={sending() || !jsEnabled()}
title="submit"
>
<Icon glyph={GravityMegaphone} size={16} />
@ -160,6 +171,7 @@ function ShoutboxInner(props: {
</div>
<div class={css.formControls}>
<Checkbox
ref={privateCheckbox}
label="make it private"
name="private"
/>
@ -191,7 +203,7 @@ function ShoutboxInner(props: {
</div>
</Show>
</div>
</form>
</div>
<div class={css.shouts}>
{shoutsRender()}

View file

@ -11,24 +11,14 @@ import { HttpResponse } from '~/backend/utils/response'
const schema = z.object({
_csrf: z.string(),
message: z.string(),
private: z.string().optional(),
private: z.boolean(),
})
const rateLimitPerIp = new RateLimiterMemory({ points: 3, duration: 300 })
const rateLimitGlobal = new RateLimiterMemory({ points: 100, duration: 3600 })
export const POST: APIRoute = async (ctx) => {
const contentType = ctx.request.headers.get('content-type')
const isFormSubmit = contentType === 'application/x-www-form-urlencoded'
let bodyRaw: unknown
if (isFormSubmit) {
bodyRaw = Object.fromEntries((await ctx.request.formData()).entries())
} else {
bodyRaw = await ctx.request.json()
}
const body = await schema.safeParseAsync(bodyRaw)
const body = await schema.safeParseAsync(await ctx.request.json())
if (body.error) {
return HttpResponse.json({
error: fromError(body.error).message,
@ -71,18 +61,13 @@ export const POST: APIRoute = async (ctx) => {
const result = await createShout({
fromIp: ip,
private: body.data.private !== undefined,
private: body.data.private,
text: body.data.message,
isFormSubmit,
})
await rateLimitPerIp.penalty(ip, 1)
await rateLimitGlobal.penalty('GLOBAL', 1)
if (isFormSubmit) {
return HttpResponse.redirect(typeof result === 'string' ? `/?shout_error=${result}` : '/')
}
return HttpResponse.json(
typeof result === 'string' ? { error: result } : { ok: true },
)