+ Suggestion Form
+
+
+ Use the form below to report missing data, submit an issue, or make a suggestion.
+
+
+
+
+ {#if error}
+
+
+ Error: {error}
+
+ {/if}
+
+ {#if url}
+
+
+ Your suggestion has been created as a
+ GitHub issue. We will review it shortly.
+
+ {/if}
+
+
+
diff --git a/src/lib/client/utils.ts b/src/lib/client/utils.ts
index febf73e8..2f0e2253 100644
--- a/src/lib/client/utils.ts
+++ b/src/lib/client/utils.ts
@@ -1,4 +1,5 @@
import { goto } from '$app/navigation'
+import type { Attachment } from 'svelte/attachments'
export function get_device_type() {
const w = window.innerWidth
@@ -25,3 +26,21 @@ export function string_to_color(str: string): string {
const h = hash % 360
return `hsl(${h}, 80%, 50%)`
}
+
+export const resize_textarea: Attachment = (textarea) => {
+ if (!(textarea instanceof HTMLTextAreaElement)) return
+
+ textarea.style.height = `${textarea.scrollHeight}px`
+ textarea.style.overflowY = 'hidden'
+
+ const adjust = () => {
+ textarea.style.height = 'auto'
+ textarea.style.height = `${textarea.scrollHeight}px`
+ }
+
+ textarea.addEventListener('input', adjust)
+
+ return () => {
+ textarea.removeEventListener('input', adjust)
+ }
+}
diff --git a/src/lib/server/redis.ts b/src/lib/server/redis.ts
new file mode 100644
index 00000000..1d1c5631
--- /dev/null
+++ b/src/lib/server/redis.ts
@@ -0,0 +1,54 @@
+import { dev } from '$app/environment'
+import { REDIS_URL } from '$env/static/private'
+import Redis from 'ioredis'
+import profanity_filter from 'leo-profanity'
+
+export const redis = new Redis(REDIS_URL, {
+ tls: { rejectUnauthorized: !dev },
+})
+
+const rate_limit_max = 2
+const rate_limit_window = 60
+const violation_window = 60 * 60
+const rate_limit_violation_limit = 5
+const profanity_violation_limit = 2
+const block_window = 60 * 60 * 24 * 30
+
+export async function is_blocked(ip: string) {
+ return (await redis.get(`blocked:ip:${ip}`)) === '1'
+}
+
+export async function rate_limit(ip: string) {
+ const key = `rate_limit:ip:${ip}`
+
+ const count = await redis.incr(key)
+
+ if (count === 1) {
+ await redis.expire(key, rate_limit_window)
+ }
+
+ return count <= rate_limit_max
+}
+
+export async function flag_violation(ip: string, type: 'rate_limit' | 'profanity') {
+ const key = `violation:ip:${ip}:${type}`
+
+ const violations = await redis.incr(key)
+
+ if (violations === 1) {
+ await redis.expire(key, violation_window)
+ }
+
+ if (type === 'rate_limit' && violations >= rate_limit_violation_limit) {
+ await redis.set(`blocked:ip:${ip}`, '1', 'EX', block_window)
+ }
+
+ if (type === 'profanity' && violations >= profanity_violation_limit) {
+ await redis.set(`blocked:ip:${ip}`, '1', 'EX', block_window)
+ }
+}
+
+export function has_profanity(title: string, body: string) {
+ const text = `${title}\n${body}`.toLowerCase()
+ return profanity_filter.check(text)
+}
diff --git a/src/routes/api/issue/+server.ts b/src/routes/api/issue/+server.ts
new file mode 100644
index 00000000..f17db0cc
--- /dev/null
+++ b/src/routes/api/issue/+server.ts
@@ -0,0 +1,120 @@
+import { json } from '@sveltejs/kit'
+import { App } from '@octokit/app'
+import { GITHUB_PRIVATE_KEY } from '$env/static/private'
+
+import {
+ BODY_MAX_LENGTH,
+ GITHUB_APP_ID,
+ GITHUB_INSTALLATION_ID,
+ GITHUB_OWNER,
+ GITHUB_REPO,
+ TITLE_MAX_LENGTH,
+ NAME_MAX_LENGTH,
+ ORIGIN,
+} from './config'
+import { flag_violation, is_blocked, has_profanity, rate_limit } from '$lib/server/redis'
+
+const app = new App({
+ appId: GITHUB_APP_ID,
+ privateKey: GITHUB_PRIVATE_KEY,
+})
+
+export const POST = async (event) => {
+ const ip = event.getClientAddress()
+
+ if (await is_blocked(ip)) {
+ return json({ error: 'Forbidden' }, { status: 403 })
+ }
+
+ if (!(await rate_limit(ip))) {
+ await flag_violation(ip, 'rate_limit')
+ return json(
+ { error: 'Too many requests. Please try again later.' },
+ { status: 429 },
+ )
+ }
+
+ const data = await parse_data(event.request)
+
+ if ('error' in data) return json({ error: data.error }, { status: 400 })
+
+ const { title, body, url, name } = data
+
+ if (has_profanity(title, body)) {
+ await flag_violation(ip, 'profanity')
+ return json({ error: 'Profanity detected' }, { status: 400 })
+ }
+
+ const footer = name
+ ? `This issue has been created by **${name}** via the submission form on ${url}`
+ : `This issue has been created via the submission form on ${url}`
+
+ const full_body = `${body}\n\n---\n${footer}`
+
+ try {
+ const octokit = await app.getInstallationOctokit(Number(GITHUB_INSTALLATION_ID))
+
+ const issue = await octokit.request('POST /repos/{owner}/{repo}/issues', {
+ owner: GITHUB_OWNER,
+ repo: GITHUB_REPO,
+ title,
+ body: full_body,
+ })
+
+ return json({ url: issue.data.html_url })
+ } catch (err) {
+ console.error(err)
+ return json({ error: 'Issue could not be created' }, { status: 502 })
+ }
+}
+
+async function parse_data(
+ request: Request,
+): Promise<
+ { error: string } | { title: string; body: string; url: string; name: string }
+> {
+ const content_type = request.headers.get('Content-Type')
+ if (content_type !== 'application/json') {
+ return { error: 'Forbidden' }
+ }
+
+ const origin = request.headers.get('origin') ?? ''
+ if (!request.url.startsWith(origin)) {
+ return { error: 'Forbidden' }
+ }
+
+ let data
+ try {
+ data = await request.json()
+ } catch (_) {
+ return { error: 'Invalid request body' }
+ }
+
+ const { title, body, url, name = '' } = data
+
+ if (!title) return { error: 'Title required' }
+ if (!body) return { error: 'Body required' }
+ if (!url) return { error: 'URL required' }
+
+ if (typeof title !== 'string') return { error: 'Title must be a string' }
+ if (typeof body !== 'string') return { error: 'Body must be a string' }
+ if (typeof url !== 'string') return { error: 'URL must be a string' }
+ if (typeof name !== 'string') return { error: 'Name must be a string' }
+
+ if (title.length > TITLE_MAX_LENGTH) {
+ return { error: `Title must have at most ${TITLE_MAX_LENGTH} characters` }
+ }
+ if (body.length > BODY_MAX_LENGTH) {
+ return { error: `Body must have at most ${BODY_MAX_LENGTH} characters` }
+ }
+
+ if (!url.startsWith('https://') && !url.startsWith('http://')) {
+ return { error: 'URL must be a valid URL' }
+ }
+
+ if (name.length > NAME_MAX_LENGTH) {
+ return { error: `Name must have at most ${NAME_MAX_LENGTH} characters` }
+ }
+
+ return { title, body, url, name }
+}
diff --git a/src/routes/api/issue/config.ts b/src/routes/api/issue/config.ts
new file mode 100644
index 00000000..8b12e99f
--- /dev/null
+++ b/src/routes/api/issue/config.ts
@@ -0,0 +1,8 @@
+export const GITHUB_APP_ID = '3330448'
+export const GITHUB_INSTALLATION_ID = '122747163'
+export const GITHUB_OWNER = 'ScriptRaccoon'
+export const GITHUB_REPO = 'CatDat'
+export const TITLE_MAX_LENGTH = 50
+export const BODY_MAX_LENGTH = 10000
+export const NAME_MAX_LENGTH = 50
+export const ORIGIN = 'https://catdat.app'
diff --git a/src/routes/app.css b/src/routes/app.css
index 0b271098..c65d8be4 100644
--- a/src/routes/app.css
+++ b/src/routes/app.css
@@ -124,15 +124,21 @@ ul li::marker {
button,
input,
-select {
+select,
+textarea {
font: inherit;
background: none;
border: none;
color: inherit;
}
+textarea {
+ resize: vertical;
+}
+
input[type='text'],
input[type='search'],
+textarea,
select {
padding: 0.25rem 0.75rem;
border-radius: 0.4rem;
@@ -158,6 +164,12 @@ input[type='search']::-ms-clear {
display: none;
}
+label {
+ display: block;
+ margin-bottom: 0.1rem;
+ font-size: 1rem;
+}
+
button:not(:disabled) {
cursor: pointer;
}
@@ -190,6 +202,10 @@ button:focus-visible {
white-space: nowrap;
}
+.full-width {
+ width: 100%;
+}
+
summary {
cursor: pointer;
width: fit-content;
diff --git a/src/routes/categories/+page.svelte b/src/routes/categories/+page.svelte
index 532bb3c5..01d01778 100644
--- a/src/routes/categories/+page.svelte
+++ b/src/routes/categories/+page.svelte
@@ -4,6 +4,7 @@
import ChipGroup from '$components/ChipGroup.svelte'
import MetaData from '$components/MetaData.svelte'
import SearchFilter from '$components/SearchFilter.svelte'
+ import SuggestionForm from '$components/SuggestionForm.svelte'
import { filter_by_tag, pluralize } from '$lib/client/utils'
let { data } = $props()
@@ -46,6 +47,8 @@
+