diff --git a/.vscode/settings.json b/.vscode/settings.json index 56a51f78..c59304cb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "python.formatting.provider": "black", - "python.analysis.extraPaths": ["${workspaceFolder}/oasst-shared"] + "python.analysis.extraPaths": ["${workspaceFolder}/oasst-shared"], + "prettier.singleQuote": false } diff --git a/docker-compose.yaml b/docker-compose.yaml index b9c9c82f..9a0202e7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -125,6 +125,9 @@ services: - EMAIL_FROM=info@example.com - NEXTAUTH_URL=http://localhost:3000 - DEBUG_LOGIN=true + - NEXT_PUBLIC_CLOUDFARE_CAPTCHA_SITE_KEY=1x00000000000000000000AA + - CLOUDFLARE_CAPTCHA_SERCERT_KEY=1x0000000000000000000000000000000AA + - NEXT_PUBLIC_ENABLE_EMAIL_SIGNIN_CAPTCHA=true depends_on: webdb: condition: service_healthy diff --git a/docs/docs/faq/faq.md b/docs/docs/faq/faq.md index 025ae02b..4b1d16eb 100644 --- a/docs/docs/faq/faq.md +++ b/docs/docs/faq/faq.md @@ -90,3 +90,17 @@ getting permission denied (using root user), you can try the following: # And remove the container docker rm -f ``` + +### Docker Port Problems + +Oftentimes people already have some Postgres instance running on the dev +machine. To avoid port problems, change the ports in the `docker-compose.yml` to +ones excluding `5433`, like: + +1. Change `db.ports` to `- 5431:5431`. +2. Add `POSTGRES_PORT: 5431` to `db.environment` +3. Change `webdb.ports` to `- 5432:5431` +4. Add `POSTGRES_PORT: 5431` to `db.environment` +5. Add `- POSTGRES_PORT=5432` to `backend.environment` +6. Change `web.environment.DATABASE_URL` to + `postgres://postgres:postgres@webdb:5432/oasst_web` diff --git a/website/.env b/website/.env index 18cbfcde..e81374e7 100644 --- a/website/.env +++ b/website/.env @@ -17,3 +17,7 @@ NEXTAUTH_SECRET=O/M2uIbGj+lDD2oyNa8ax4jEOJqCPJzO53UbWShmq98= EMAIL_SERVER_HOST=localhost EMAIL_SERVER_PORT=1025 EMAIL_FROM=info@example.com + +NEXT_PUBLIC_CLOUDFLARE_CAPTCHA_SITE_KEY=1x00000000000000000000AA +CLOUDFLARE_CAPTCHA_SERCERT_KEY=1x0000000000000000000000000000000AA +NEXT_PUBLIC_ENABLE_EMAIL_SIGNIN_CAPTCHA=false diff --git a/website/cypress/e2e/auth/signin.cy.ts b/website/cypress/e2e/auth/signin.cy.ts index 2a651f1f..9d6e63e1 100644 --- a/website/cypress/e2e/auth/signin.cy.ts +++ b/website/cypress/e2e/auth/signin.cy.ts @@ -14,11 +14,16 @@ describe("signin flow", () => { cy.request("GET", "/api/auth/csrf") .then((response) => { const csrfToken = response.body.csrfToken; - cy.request("POST", "/api/auth/signin/email", { - callbackUrl: "/", - email: emailAddress, - csrfToken, - json: "true", + cy.request({ + method: "POST", + url: "/api/auth/signin/email", + body: { + callbackUrl: "/", + email: emailAddress, + csrfToken, + json: "true", + captcha: "XXXX.DUMMY.TOKEN.XXXX", + }, }); }) .then((response) => { diff --git a/website/cypress/support/commands.ts b/website/cypress/support/commands.ts index 2507116d..b2ce81f6 100644 --- a/website/cypress/support/commands.ts +++ b/website/cypress/support/commands.ts @@ -54,11 +54,16 @@ Cypress.Commands.add("signInWithEmail", (emailAddress) => { cy.request("GET", "/api/auth/csrf") .then((response) => { const csrfToken = response.body.csrfToken; - cy.request("POST", "/api/auth/signin/email", { - callbackUrl: "/", - email: emailAddress, - csrfToken, - json: "true", + cy.request({ + method: "POST", + url: "/api/auth/signin/email", + body: { + callbackUrl: "/", + email: emailAddress, + csrfToken, + json: "true", + captcha: "XXXX.DUMMY.TOKEN.XXXX", + }, }); }) .then(() => { diff --git a/website/package-lock.json b/website/package-lock.json index 4cba1b5f..43c34208 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -15,6 +15,7 @@ "@dnd-kit/utilities": "^3.2.1", "@emotion/react": "^11.10.5", "@emotion/styled": "^11.10.5", + "@marsidev/react-turnstile": "^0.0.7", "@next-auth/prisma-adapter": "^1.0.5", "@next/font": "^13.1.0", "@prisma/client": "^4.7.1", @@ -5518,6 +5519,15 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@marsidev/react-turnstile": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-0.0.7.tgz", + "integrity": "sha512-BWXZ6/ddE96cP/U3jkLO8wbJi6qOpE4wH67h6EkD57TRy4RSauBBYKRZkuUEpfgm2wdWoPukPz4LfnoV5KJGrQ==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@mdx-js/mdx": { "version": "1.6.22", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz", @@ -42272,6 +42282,12 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "@marsidev/react-turnstile": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-0.0.7.tgz", + "integrity": "sha512-BWXZ6/ddE96cP/U3jkLO8wbJi6qOpE4wH67h6EkD57TRy4RSauBBYKRZkuUEpfgm2wdWoPukPz4LfnoV5KJGrQ==", + "requires": {} + }, "@mdx-js/mdx": { "version": "1.6.22", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz", diff --git a/website/package.json b/website/package.json index c488417b..4a56612e 100644 --- a/website/package.json +++ b/website/package.json @@ -32,6 +32,7 @@ "@dnd-kit/utilities": "^3.2.1", "@emotion/react": "^11.10.5", "@emotion/styled": "^11.10.5", + "@marsidev/react-turnstile": "^0.0.7", "@next-auth/prisma-adapter": "^1.0.5", "@next/font": "^13.1.0", "@prisma/client": "^4.7.1", diff --git a/website/public/locales/en/tasks.json b/website/public/locales/en/tasks.json index 53cfa088..7fa7c452 100644 --- a/website/public/locales/en/tasks.json +++ b/website/public/locales/en/tasks.json @@ -77,5 +77,6 @@ "label": "Classify Assistant Reply", "desc": "Provide labels for a prompt.", "overview": "Read the following conversation and then answer the question about the last reply in the discussion." - } + }, + "available_task_count": "{{count}} tasks available" } diff --git a/website/src/components/CloudflareCaptcha.tsx b/website/src/components/CloudflareCaptcha.tsx new file mode 100644 index 00000000..550e7e25 --- /dev/null +++ b/website/src/components/CloudflareCaptcha.tsx @@ -0,0 +1,20 @@ +import { useColorMode } from "@chakra-ui/react"; +import { Turnstile, TurnstileInstance, TurnstileProps } from "@marsidev/react-turnstile"; +import { forwardRef } from "react"; + +export const CloudFlareCaptcha = forwardRef>((props, ref) => { + const { colorMode } = useColorMode(); + return ( + + ); +}); + +CloudFlareCaptcha.displayName = "CloudFlareCaptcha"; diff --git a/website/src/components/CollapsableText.tsx b/website/src/components/CollapsableText.tsx index 21325d29..9be43c4b 100644 --- a/website/src/components/CollapsableText.tsx +++ b/website/src/components/CollapsableText.tsx @@ -51,7 +51,7 @@ export const CollapsableText = ({ Full Text - {text} + {text} diff --git a/website/src/components/Dashboard/TaskOption.tsx b/website/src/components/Dashboard/TaskOption.tsx index 0401fd97..55236b93 100644 --- a/website/src/components/Dashboard/TaskOption.tsx +++ b/website/src/components/Dashboard/TaskOption.tsx @@ -1,5 +1,7 @@ import { + Badge, Box, + Card, Flex, GridItem, Heading, @@ -8,7 +10,6 @@ import { SimpleGrid, Spacer, Text, - useColorModeValue, } from "@chakra-ui/react"; import { HelpCircle } from "lucide-react"; import Link from "next/link"; @@ -19,19 +20,20 @@ import { TaskCategory, TaskInfo, TaskType } from "src/types/Task"; import { TaskCategoryLabels, TaskInfos } from "../Tasks/TaskTypes"; +export type TaskCategoryItem = { taskType: TaskType; count: number }; + export interface TasksOptionProps { - content: Partial>; + content: Partial>; } export const TaskOption = ({ content }: TasksOptionProps) => { const { t } = useTranslation(["dashboard", "tasks"]); - const backgroundColor = useColorModeValue("white", "gray.700"); const taskInfoMap = useMemo( () => Object.values(content) .flat() - .reduce((obj, taskType) => { + .reduce((obj, { taskType }) => { obj[taskType] = TaskInfos.filter((t) => t.type === taskType).pop(); return obj; }, {} as Record), @@ -40,7 +42,7 @@ export const TaskOption = ({ content }: TasksOptionProps) => { return ( - {Object.entries(content).map(([category, taskTypes]) => ( + {Object.entries(content).map(([category, items]) => (
@@ -52,24 +54,25 @@ export const TaskOption = ({ content }: TasksOptionProps) => { - {taskTypes - .map((taskType) => taskInfoMap[taskType]) + {items + .map(({ taskType, count }) => ({ ...taskInfoMap[taskType], count })) .map((item) => ( - - - {t(getTypeSafei18nKey(`tasks:${item.id}.label`))} - {t(getTypeSafei18nKey(`tasks:${item.id}.desc`))} + + + + {t(getTypeSafei18nKey(`tasks:${item.id}.label`))} + {t(getTypeSafei18nKey(`tasks:${item.id}.desc`))} + + + {t("tasks:available_task_count", { count: item.count })} + {t("go")} -> @@ -85,12 +88,20 @@ export const TaskOption = ({ content }: TasksOptionProps) => { }; export const allTaskOptions: TasksOptionProps["content"] = { - [TaskCategory.Random]: [TaskType.random], - [TaskCategory.Create]: [TaskType.initial_prompt, TaskType.prompter_reply, TaskType.assistant_reply], - [TaskCategory.Evaluate]: [ - TaskType.rank_initial_prompts, - TaskType.rank_prompter_replies, - TaskType.rank_assistant_replies, + [TaskCategory.Random]: [{ taskType: TaskType.random, count: 0 }], + [TaskCategory.Create]: [ + { taskType: TaskType.initial_prompt, count: 0 }, + { taskType: TaskType.prompter_reply, count: 0 }, + { taskType: TaskType.assistant_reply, count: 0 }, + ], + [TaskCategory.Evaluate]: [ + { taskType: TaskType.rank_initial_prompts, count: 0 }, + { taskType: TaskType.rank_prompter_replies, count: 0 }, + { taskType: TaskType.rank_assistant_replies, count: 0 }, + ], + [TaskCategory.Label]: [ + { taskType: TaskType.label_initial_prompt, count: 0 }, + { taskType: TaskType.label_prompter_reply, count: 0 }, + { taskType: TaskType.label_assistant_reply, count: 0 }, ], - [TaskCategory.Label]: [TaskType.label_initial_prompt, TaskType.label_prompter_reply, TaskType.label_assistant_reply], }; diff --git a/website/src/components/Messages.tsx b/website/src/components/Messages.tsx deleted file mode 100644 index 58e0d2be..00000000 --- a/website/src/components/Messages.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Box, forwardRef, useColorMode } from "@chakra-ui/react"; -import { useMemo } from "react"; -import { Message } from "src/types/Conversation"; - -export const MessageView = forwardRef, "div">((message: Partial, ref) => { - const { colorMode } = useColorMode(); - - const bgColor = useMemo(() => { - if (colorMode === "light") { - return message.is_assistant ? "gray.800" : "blue.600"; - } else { - return message.is_assistant ? "black" : "blue.600"; - } - }, [colorMode, message.is_assistant]); - - return ( - - {message.text} - - ); -}); - -MessageView.displayName = "MessageView"; diff --git a/website/src/components/Tasks/LabelTask/LabelTask.tsx b/website/src/components/Tasks/LabelTask/LabelTask.tsx index 33152ba1..33369757 100644 --- a/website/src/components/Tasks/LabelTask/LabelTask.tsx +++ b/website/src/components/Tasks/LabelTask/LabelTask.tsx @@ -1,13 +1,11 @@ import { Box, useBoolean, useColorModeValue } from "@chakra-ui/react"; import { useTranslation } from "next-i18next"; import { useEffect, useState } from "react"; -import { MessageView } from "src/components/Messages"; import { LabelInputGroup } from "src/components/Messages/LabelInputGroup"; import { MessageTable } from "src/components/Messages/MessageTable"; import { TwoColumnsWithCards } from "src/components/Survey/TwoColumnsWithCards"; import { TaskSurveyProps } from "src/components/Tasks/Task"; import { TaskHeader } from "src/components/Tasks/TaskHeader"; -import { TaskType } from "src/types/Task"; import { LabelTaskType } from "src/types/Tasks"; const isRequired = (labelName: string, requiredLabels?: string[]) => { @@ -56,15 +54,9 @@ export const LabelTask = ({ <> - {task.type !== TaskType.label_initial_prompt ? ( - - - - ) : ( - - - - )} + + + => { + const data = new FormData(); + + data.append("secret", process.env.CLOUDFLARE_CAPTCHA_SERCERT_KEY); + data.append("response", token); + data.append("remoteip", ipAdress); + + const result = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { + body: data, + method: "POST", + }); + + const res: CheckCaptchaResponse = await result.json(); + return { + ...res, + success: getSuccess(res, options?.action, options?.cdata), + }; +}; + +// This function hasn't been tested yet, Cloudflare doesn't send `action` and `cdata` with a demo key. +const getSuccess = (response: CheckCaptchaResponse, action: string | undefined, cdata: string | undefined) => { + if (action === undefined && cdata === undefined) { + return response.success; + } + + if (action) { + if (cdata) { + return response.action === action && response.cdata === cdata; + } + return response.action === action; + } + + return false; +}; diff --git a/website/src/pages/api/auth/[...nextauth].ts b/website/src/pages/api/auth/[...nextauth].ts index 7a5ddbe4..5a316256 100644 --- a/website/src/pages/api/auth/[...nextauth].ts +++ b/website/src/pages/api/auth/[...nextauth].ts @@ -1,11 +1,13 @@ import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { boolean } from "boolean"; +import { NextApiRequest, NextApiResponse } from "next"; import type { AuthOptions } from "next-auth"; import NextAuth from "next-auth"; import { Provider } from "next-auth/providers"; import CredentialsProvider from "next-auth/providers/credentials"; import DiscordProvider from "next-auth/providers/discord"; import EmailProvider from "next-auth/providers/email"; +import { checkCaptcha } from "src/lib/captcha"; import prisma from "src/lib/prismadb"; import { generateUsername } from "unique-username-generator"; @@ -74,7 +76,7 @@ const adminUserMap = process.env.ADMIN_USERS.split(",").reduce((result, entry) = return result; }, new Map()); -export const authOptions: AuthOptions = { +const authOptions: AuthOptions = { // Ensure we can store user data in a database. adapter: PrismaAdapter(prisma), providers, @@ -148,24 +150,41 @@ export const authOptions: AuthOptions = { } }, }, - /* - * We maybe need this, we maybe don't. Checking in this uncommented until - * it's confirmed we can drop this. - cookies: { - sessionToken: { - name: `next-auth.session-token`, - options: { - httpOnly: true, - sameSite: "none", - path: "/", - secure: true, - }, - }, - }, - */ session: { strategy: "jwt", }, }; -export default NextAuth(authOptions); +export default function auth(req: NextApiRequest, res: NextApiResponse) { + return NextAuth(req, res, { + ...authOptions, + callbacks: { + ...authOptions.callbacks, + async signIn({ account }) { + if (account.provider !== "email" || !boolean(process.env.NEXT_PUBLIC_ENABLE_EMAIL_SIGNIN_CAPTCHA)) { + return true; + } + + const captcha = req.body.captcha; + + const res = await checkCaptcha(captcha, getIp(req)); + + if (res.success) { + return true; + } + + return "/auth/signin?error=InvalidCaptcha"; + }, + }, + }); +} + +const getIp = (req: NextApiRequest) => { + try { + // https://stackoverflow.com/questions/66111742/get-the-client-ip-on-nextjs-and-use-ssr + const forwarded = req.headers["x-forwarded-for"]; + return typeof forwarded === "string" ? forwarded.split(/, /)[0] : req.socket.remoteAddress; + } catch { + return ""; + } +}; diff --git a/website/src/pages/auth/signin.tsx b/website/src/pages/auth/signin.tsx index d171182d..b65fa742 100644 --- a/website/src/pages/auth/signin.tsx +++ b/website/src/pages/auth/signin.tsx @@ -1,15 +1,18 @@ import { Button, ButtonProps, Input, Stack, useColorModeValue } from "@chakra-ui/react"; import { useColorMode } from "@chakra-ui/react"; +import { TurnstileInstance } from "@marsidev/react-turnstile"; +import { boolean } from "boolean"; import { Bug, Github, Mail } from "lucide-react"; import { GetServerSideProps } from "next"; import Head from "next/head"; import Link from "next/link"; import { useRouter } from "next/router"; -import { ClientSafeProvider, getProviders, signIn } from "next-auth/react"; +import { getProviders, signIn } from "next-auth/react"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { AuthLayout } from "src/components/AuthLayout"; +import { CloudFlareCaptcha } from "src/components/CloudflareCaptcha"; import { Footer } from "src/components/Footer"; import { Header } from "src/components/Header"; import { Discord } from "src/components/Icons/Discord"; @@ -26,6 +29,7 @@ export type SignInErrorTypes = | "EmailSignin" | "CredentialsSignin" | "SessionRequired" + | "InvalidCaptcha" | "default"; const errorMessages: Record = { @@ -39,6 +43,7 @@ const errorMessages: Record = { EmailSignin: "The e-mail could not be sent.", CredentialsSignin: "Sign in failed. Check the details you provided are correct.", SessionRequired: "Please sign in to access this page.", + InvalidCaptcha: "Invalid captcha", default: "Unable to sign in.", }; @@ -62,14 +67,10 @@ function Signin({ providers }: SigninProps) { } }, [router]); - const signinWithEmail = (data: { email: string }) => { - signIn(email.id, { callbackUrl: "/dashboard", email: data.email }); - }; - const { colorMode } = useColorMode(); const bgColorClass = colorMode === "light" ? "bg-gray-50" : "bg-chakra-gray-900"; const buttonBgColor = colorMode === "light" ? "#2563eb" : "#2563eb"; - const { register, handleSubmit } = useForm<{ email: string }>(); + return (
@@ -78,24 +79,8 @@ function Signin({ providers }: SigninProps) { - {credentials && } - {email && ( -
- - - }> - Continue with Email - - -
- )} + {credentials && } + {email && } {discord && (