diff --git a/src/app/(platform)/settings/advanced/actions.ts b/src/app/(platform)/settings/advanced/actions.ts new file mode 100644 index 0000000..a2948d6 --- /dev/null +++ b/src/app/(platform)/settings/advanced/actions.ts @@ -0,0 +1,68 @@ +"use server"; + +import { headers } from "next/headers"; + +import { eq } from "drizzle-orm"; +import { UTApi } from "uploadthing/server"; + +import { db } from "~/server/db"; +import { users } from "~/server/db/schema"; +import { ExpiringTokenBucket, RefillingTokenBucket } from "~/server/rate-limit"; +import { globalPOSTRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +const ipBucket = new RefillingTokenBucket(3, 10); + +export async function updateAdvancedAction( + _prev: ActionResult, + formData: FormData, +): Promise { + if (!(await globalPOSTRateLimit())) { + return { + message: "Too many requests", + }; + } + // FIXME: Assumes X-Forwarded-For is always included. + const clientIP = (await headers()).get("X-Forwarded-For"); + if (clientIP !== null && !ipBucket.check(clientIP, 1)) { + return { + message: "Too many requests", + }; + } + + const { session, user } = await getCurrentSession(); + + if (session === null) { + return { + message: "Not authenticated", + }; + } + if (user.registered2FA && !session.twoFactorVerified) { + return { + message: "Forbidden", + }; + } + + const instance = formData.get("instance"); + + if (typeof instance !== "string") { + return { + message: "Invalid or missing fields", + }; + } + + await db + .update(users) + .set({ + aiTaggingInstance: typeof instance === "string" ? instance : undefined, + }) + .where(eq(users.id, user.id)); + + return { + message: "Updated profile settings", + }; +} + +interface ActionResult { + message: string; +} \ No newline at end of file diff --git a/src/app/(platform)/settings/advanced/page.tsx b/src/app/(platform)/settings/advanced/page.tsx new file mode 100644 index 0000000..bb84268 --- /dev/null +++ b/src/app/(platform)/settings/advanced/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from "next/navigation"; + +import { get2FARedirect } from "~/server/2fa"; +import { globalGETRateLimit } from "~/server/request"; +import { getCurrentSession } from "~/server/session"; + +import AdvancedSettings from "~/components/advanced-settings"; + +export default async function Page() { + if (!(await globalGETRateLimit())) { + return "Too many requests"; + } + + const { session, user } = await getCurrentSession(); + if (session === null) { + return redirect("/log-in"); + } + if (user.registered2FA && !session.twoFactorVerified) { + return redirect(get2FARedirect(user)); + } + + return ; +} diff --git a/src/components/advanced-settings.tsx b/src/components/advanced-settings.tsx new file mode 100644 index 0000000..23d8ff8 --- /dev/null +++ b/src/components/advanced-settings.tsx @@ -0,0 +1,114 @@ +"use client"; + +import * as React from "react"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import type { User } from "~/server/models"; + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { + Input +} from "~/components/ui/input"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { BotIcon, RotateCcw } from "lucide-react"; +import { Switch } from "./ui/switch"; +import { Label } from "./ui/label"; + +import { + updateAdvancedAction, +} from "~/app/(platform)/settings/advanced/actions"; +import { Button } from "./ui/button"; + + +const FormSchema = z.object({ + enabled: z.boolean(), + instance: z.string().url().optional(), +}); + +const initialState = { + message: "", +}; + +export default function AdvancedSettings(props: { user: User }) { + const [, action] = React.useActionState(updateAdvancedAction, initialState); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + enabled: (props.user.aiTaggingInstance != null && props.user.aiTaggingInstance.length > 0) ?? false, + instance: (props.user.aiTaggingInstance != null && props.user.aiTaggingInstance.length > 0) ? props.user.aiTaggingInstance : "https://ai.hackclub.com", + }, + }); + + return ( +
+ + + + Hey there! + + + We understand that clankers generative AI offer both convience + and pitfalls. That's why Regreso offerrs thoughtful, opt-in AI features + which give you maximum control. Use our default instance or provide your own. + + +
+ + + ( + + + + + + Enable AI Tagging + + + + + )} + /> + {form.watch("enabled") && ( + ( + + + AI Tagging Instance + + +
+ + +
+
+ + +
+ )} + /> + )} + + + + +
+ ); +} diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 0d0ff37..0d68647 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -127,6 +127,25 @@ export const userRouter = createTRPCRouter({ await updateUserPassword(ctx.user.id, input.newPassword); return { success: true }; }), + updateAITaggingInstance: protectedMutationProcedure + .meta({ + openapi: { method: "PATCH", path: "/v1/user/ai-tagging-instance", protect: true }, + }) + .input(z.object({ + instance: z.string().url().optional(), + })) + .output(z.object({ success: z.boolean() })) + .mutation(async ({ ctx, input }) => { + await ctx.db + .update(users) + .set({ + aiTaggingInstance: input.instance, + }) + .where(eq(users.id, ctx.user.id)); + + return { success: true }; + } + ), // delete: protectedMutationProcedure // .meta({