diff --git a/packages/backend/middleware/rateLimiters.ts b/packages/backend/middleware/rateLimiters.ts index 53c4115..dc18387 100644 --- a/packages/backend/middleware/rateLimiters.ts +++ b/packages/backend/middleware/rateLimiters.ts @@ -6,8 +6,8 @@ import { RateLimiter } from "@koshnic/ratelimit"; import { ErrorResponse } from "@/src/schema"; import { redis } from "@core/db/redis.ts"; -export const getIdentifier = (c: Context, includeIP: boolean = true) => { - let ipAddr = generateRandomId(6); +export const getUserIP = (c: Context) => { + let ipAddr = null; const info = getConnInfo(c); if (info.remote && info.remote.address) { ipAddr = info.remote.address; @@ -16,6 +16,14 @@ export const getIdentifier = (c: Context, includeIP: boolean = true) => { if (forwardedFor) { ipAddr = forwardedFor.split(",")[0]; } + return ipAddr; +}; + +export const getIdentifier = (c: Context, includeIP: boolean = true) => { + let ipAddr = generateRandomId(6); + if (getUserIP(c)) { + ipAddr = getUserIP(c); + } const path = c.req.path; const method = c.req.method; const ipIdentifier = includeIP ? `@${ipAddr}` : ""; @@ -41,4 +49,4 @@ export const registerRateLimiter = async (c: Context, nex } await next(); -}; \ No newline at end of file +}; diff --git a/packages/backend/routes/captcha/[id]/result/GET.ts b/packages/backend/routes/captcha/[id]/result/GET.ts index 25d307c..19a4c5a 100644 --- a/packages/backend/routes/captcha/[id]/result/GET.ts +++ b/packages/backend/routes/captcha/[id]/result/GET.ts @@ -2,7 +2,7 @@ import { Context } from "hono"; import { Bindings, BlankEnv } from "hono/types"; import { ErrorResponse } from "src/schema"; import { createHandlers } from "src/utils.ts"; -import { sign } from 'hono/jwt' +import { sign } from "hono/jwt"; import { generateRandomId } from "@core/lib/randomID.ts"; import { getJWTsecret } from "lib/auth/getJWTsecret.ts"; @@ -43,7 +43,10 @@ export const verifyChallengeHandler = createHandlers( if (data.error && res.status === 404) { const response: ErrorResponse = { message: data.error, - code: "ENTITY_NOT_FOUND" + code: "ENTITY_NOT_FOUND", + i18n: { + key: "backend.error.captcha_not_found" + } }; return c.json(response, 401); } else if (data.error && res.status === 400) { @@ -74,13 +77,16 @@ export const verifyChallengeHandler = createHandlers( const jwtSecret = r as string; const tokenID = generateRandomId(6); - const NOW = Math.floor(Date.now() / 1000) + const NOW = Math.floor(Date.now() / 1000); const FIVE_MINUTES_LATER = NOW + 60 * 5; - const jwt = await sign({ - difficulty: data.difficulty!, - id: tokenID, - exp: FIVE_MINUTES_LATER - }, jwtSecret); + const jwt = await sign( + { + difficulty: data.difficulty!, + id: tokenID, + exp: FIVE_MINUTES_LATER + }, + jwtSecret + ); return c.json({ token: jwt }); diff --git a/packages/backend/routes/captcha/index.ts b/packages/backend/routes/captcha/index.ts index fa6d476..90b27e5 100644 --- a/packages/backend/routes/captcha/index.ts +++ b/packages/backend/routes/captcha/index.ts @@ -1,2 +1,2 @@ export * from "./session/POST.ts"; -export * from "./[id]/result/GET.ts"; \ No newline at end of file +export * from "./[id]/result/GET.ts"; diff --git a/packages/backend/routes/captcha/session/POST.ts b/packages/backend/routes/captcha/session/POST.ts index 2686a77..0836368 100644 --- a/packages/backend/routes/captcha/session/POST.ts +++ b/packages/backend/routes/captcha/session/POST.ts @@ -2,58 +2,50 @@ import { createHandlers } from "src/utils.ts"; import { getCurrentCaptchaDifficulty } from "@/lib/auth/captchaDifficulty.ts"; import { sqlCred } from "@core/db/dbNew.ts"; import { object, string, ValidationError } from "yup"; -import { ErrorResponse } from "@/src/schema"; +import { CaptchaSessionResponse, ErrorResponse } from "@/src/schema"; import type { ContentfulStatusCode } from "hono/utils/http-status"; const bodySchema = object({ - route: string().matches(/(?:GET|POST|PUT|PATCH|DELETE)-\/.*/g) + route: string().matches(/(?:GET|POST|PUT|PATCH|DELETE)-\/.*/g) }); -interface CaptchaSessionResponse { - success: boolean; - id: string; - g: string; - n: string; - t: number; -} - const createNewChallenge = async (difficulty: number) => { - const baseURL = process.env["UCAPTCHA_URL"]; - const url = new URL(baseURL); - url.pathname = "/challenge"; - return await fetch(url.toString(), { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - difficulty: difficulty, - }) - }); -} + const baseURL = process.env["UCAPTCHA_URL"]; + const url = new URL(baseURL); + url.pathname = "/challenge"; + return await fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + difficulty: difficulty + }) + }); +}; export const createCaptchaSessionHandler = createHandlers(async (c) => { - try { - const requestBody = await bodySchema.validate(await c.req.json()); - const { route } = requestBody; - const difficuly = await getCurrentCaptchaDifficulty(sqlCred, route) - const res = await createNewChallenge(difficuly); - return c.json(await res.json(), res.status as ContentfulStatusCode); - } catch (e: unknown) { - if (e instanceof ValidationError) { - const response: ErrorResponse = { - code: "INVALID_QUERY_PARAMS", - message: "Invalid query parameters", - errors: e.errors - }; - return c.json(response, 400); - } else { - const response: ErrorResponse = { - code: "UNKNOWN_ERROR", - message: "Unknown error", - errors: [e] - }; - return c.json>(response, 500); - } - } + try { + const requestBody = await bodySchema.validate(await c.req.json()); + const { route } = requestBody; + const difficuly = await getCurrentCaptchaDifficulty(sqlCred, route); + const res = await createNewChallenge(difficuly); + return c.json(await res.json(), res.status as ContentfulStatusCode); + } catch (e: unknown) { + if (e instanceof ValidationError) { + const response: ErrorResponse = { + code: "INVALID_QUERY_PARAMS", + message: "Invalid query parameters", + errors: e.errors + }; + return c.json(response, 400); + } else { + const response: ErrorResponse = { + code: "UNKNOWN_ERROR", + message: "Unknown error", + errors: [e] + }; + return c.json>(response, 500); + } + } }); diff --git a/packages/backend/routes/user/register.ts b/packages/backend/routes/user/register.ts index d774b56..ccab0af 100644 --- a/packages/backend/routes/user/register.ts +++ b/packages/backend/routes/user/register.ts @@ -5,6 +5,8 @@ import type { Context } from "hono"; import type { Bindings, BlankEnv, BlankInput } from "hono/types"; import { sqlCred } from "@core/db/dbNew.ts"; import { ErrorResponse, StatusResponse } from "src/schema"; +import { generateRandomId } from "@core/lib/randomID"; +import { getUserIP } from "@/middleware/rateLimiters"; const RegistrationBodySchema = object({ username: string().trim().required("Username is required").max(50, "Username cannot exceed 50 characters"), @@ -23,16 +25,46 @@ export const userExists = async (username: string) => { return result.length > 0; }; +const createLoginSession = async (uid: number, ua?: string, ip?: string) => { + const ip_address = ip || null; + const user_agent = ua || null; + const id = generateRandomId(16); + await sqlCred` + INSERT INTO login_sessions (id, uid, expire_at, ip_address, user_agent) + VALUES (${id}, ${uid}, CURRENT_TIMESTAMP + INTERVAL '1 year', ${ip_address}, ${user_agent}) + `; +}; + +const getUserIDByName = async (username: string) => { + const result = await sqlCred<{ id: number }[]>` + SELECT id + FROM users + WHERE username = ${username} + `; + if (result.length === 0) { + return null; + } + return result[0].id; +}; + export const registerHandler = createHandlers(async (c: ContextType) => { try { const body = await RegistrationBodySchema.validate(await c.req.json()); const { username, password, nickname } = body; if (await userExists(username)) { - const response: StatusResponse = { - message: `User "${username}" already exists.` + const response: ErrorResponse = { + message: `User "${username}" already exists.`, + code: "ENTITY_EXISTS", + errors: [], + i18n: { + key: "backend.error.user_exists", + values: { + username: username + } + } }; - return c.json(response, 400); + return c.json(response, 400); } const hash = await Argon2id.hashEncoded(password); @@ -42,6 +74,25 @@ export const registerHandler = createHandlers(async (c: ContextType) => { VALUES (${username}, ${hash}, ${nickname ? nickname : null}) `; + const uid = await getUserIDByName(username); + + if (!uid) { + const response: ErrorResponse = { + message: "Cannot find registered user.", + errors: [`Cannot find user ${username} in table 'users'.`], + code: "ENTITY_NOT_FOUND", + i18n: { + key: "backend.error.user_not_found_after_register", + values: { + username: username + } + } + }; + return c.json>(response, 500); + } + + createLoginSession(uid, c.req.header("User-Agent"), getUserIP(c)); + const response: StatusResponse = { message: `User '${username}' registered successfully.` }; diff --git a/packages/backend/src/schema.d.ts b/packages/backend/src/schema.d.ts index 5b16743..ab9a7bc 100644 --- a/packages/backend/src/schema.d.ts +++ b/packages/backend/src/schema.d.ts @@ -9,14 +9,39 @@ type ErrorCode = | "INVALID_CREDENTIALS" | "ENTITY_NOT_FOUND" | "SERVER_ERROR" - | "RATE_LIMIT_EXCEEDED"; + | "RATE_LIMIT_EXCEEDED" + | "ENTITY_EXISTS"; -export interface ErrorResponse { +export interface ErrorResponse { code: ErrorCode; message: string; - errors?: E[]; + errors: E[] = []; + i18n?: { + key: string; + values?: { + [key: string]: string | number | Date; + }; + }; } export interface StatusResponse { message: string; } + +export type CaptchaSessionResponse = ErrorResponse | CaptchaSessionRawResponse; + +interface CaptchaSessionRawResponse { + success: boolean; + id: string; + g: string; + n: string; + t: number; +} + +export type CaptchaVerificationRawResponse = { + token: string; +} + +export type CaptchaVerificationResponse = + | ErrorResponse + | CaptchaVerificationRawResponse; diff --git a/packages/next/app/[locale]/signup/SignUpForm.tsx b/packages/next/app/[locale]/signup/SignUpForm.tsx index 8341f55..01aac95 100644 --- a/packages/next/app/[locale]/signup/SignUpForm.tsx +++ b/packages/next/app/[locale]/signup/SignUpForm.tsx @@ -1,10 +1,8 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import TextField from "@/components/ui/TextField"; import LoadingSpinner from "@/components/icons/LoadingSpinner"; -import { computeVdfInWorker } from "@/lib/vdf"; -import useSWR from "swr"; import { ApiRequestError } from "@/lib/net"; import { Portal } from "@/components/utils/Portal"; import { Dialog, DialogButton, DialogButtonGroup, DialogHeadline, DialogSupportingText } from "@/components/ui/Dialog"; @@ -12,6 +10,9 @@ import { FilledButton } from "@/components/ui/Buttons/FilledButton"; import { string, object, ValidationError } from "yup"; import { setLocale } from "yup"; import { useTranslations } from "next-intl"; +import type { ErrorResponse } from "@backend/src/schema"; +import Link from "next/link"; +import { useCaptcha } from "@/components/hooks/useCaptcha"; setLocale({ mixed: { @@ -37,32 +38,27 @@ const FormSchema = object().shape({ nickname: string().optional().max(30) }); -interface CaptchaSessionResponse { - g: string; - n: string; - t: string; - id: string; -} - -interface CaptchaResultResponse { - token: string; -} - -async function fetcher(input: RequestInfo, init?: RequestInit): Promise { - const res = await fetch(input, init); - if (!res.ok) { - const error = new ApiRequestError("An error occurred while fetching the data."); - error.response = await res.json(); - error.code = res.status; - throw error; - } - return res.json(); -} - interface RegistrationFormProps { backendURL: string; } +interface ErrorDialogProps { + children: React.ReactNode; + closeDialog: () => void; +} + +const ErrorDialog: React.FC = ({ children, closeDialog }) => { + return ( + <> + 错误 + {children} + + 关闭 + + + ); +}; + const SignUpForm: React.FC = ({ backendURL }) => { const [usernameInput, setUsername] = useState(""); const [passwordInput, setPassword] = useState(""); @@ -71,29 +67,10 @@ const SignUpForm: React.FC = ({ backendURL }) => { const [showDialog, setShowDialog] = useState(false); const [dialogContent, setDialogContent] = useState(<>); const t = useTranslations(""); - - const { - data: captchaSession, - error: captchaSessionError, - mutate: createCaptchaSession - } = useSWR( - `${backendURL}/captcha/session`, - (url) => - fetcher(url, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ route: "POST-/user" }) - }), - { revalidateOnFocus: false, revalidateOnReconnect: false } - ); - - const getCaptchaResult = async (id: string, ans: string): Promise => { - const url = new URL(`${backendURL}/captcha/${id}/result`); - url.searchParams.set("ans", ans); - return fetcher(url.toString()); - }; + const { startCaptcha, captchaResult } = useCaptcha({ + backendURL, + route: "POST-/user" + }); const translateErrorMessage = (item: LocalizedMessage | string, path?: string) => { if (typeof item === "string") { @@ -125,40 +102,24 @@ const SignUpForm: React.FC = ({ backendURL }) => { console.log(JSON.parse(JSON.stringify(e))); setShowDialog(true); setDialogContent( - <> - 错误 - -

注册信息填写有误,请检查后重新提交。

- 错误信息: -
-
    - {e.errors.map((item, i) => { - return
  1. {translateErrorMessage(item, e.inner[i].path)}
  2. ; - })} -
-
- - setShowDialog(false)}>关闭 - - + setShowDialog(false)}> +

注册信息填写有误,请检查后重新提交。

+ 错误信息: +
+
    + {e.errors.map((item, i) => { + return
  1. {translateErrorMessage(item, e.inner[i].path)}
  2. ; + })} +
+
); return; } setLoading(true); try { - if (!captchaSession?.g || !captchaSession?.n || !captchaSession?.t || !captchaSession?.id) { - console.error("Captcha session data is missing."); - return; - } - const ans = await computeVdfInWorker( - BigInt(captchaSession.g), - BigInt(captchaSession.n), - BigInt(captchaSession.t) - ); - const captchaResult = await getCaptchaResult(captchaSession.id, ans.result.toString()); - - if (!captchaResult.token) { + if (!captchaResult || !captchaResult.token) { + await startCaptcha(); } // Proceed with user registration using username, password, and nickname const registrationUrl = new URL(`${backendURL}/user`); @@ -166,7 +127,7 @@ const SignUpForm: React.FC = ({ backendURL }) => { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${captchaResult.token}` + Authorization: `Bearer ${captchaResult!.token}` }, body: JSON.stringify({ username: username, @@ -180,17 +141,50 @@ const SignUpForm: React.FC = ({ backendURL }) => { // Optionally redirect the user or show a success message //router.push("/login"); // Example redirection } else { - console.error("Registration failed:", await registrationResponse.json()); - // Handle registration error + const res: ErrorResponse = await registrationResponse.json(); + setShowDialog(true); + setDialogContent( + setShowDialog(false)}> +

无法为你注册账户。

+

错误码: {res.code}

+

+ 错误信息:
+ {res.i18n ? t(res.i18n.key, { ...res.i18n.values }) : res.message} +

+
+ ); } } catch (error) { - console.error("Registration process error:", error); - // Handle general error + if (error instanceof ApiRequestError) { + const res = error.response as ErrorResponse; + setShowDialog(true); + setDialogContent( + setShowDialog(false)}> +

无法为你注册账户。

+

错误码: {res.code}

+

+ 错误信息:
+ {res.i18n + ? t.rich(res.i18n.key, { + ...res.i18n.values, + support: (chunks) => {chunks} + }) + : res.message} +

+
+ ); + } } finally { setLoading(false); } }; + useEffect(() => { + if (startCaptcha) { + startCaptcha(); + } + }, [startCaptcha]); + return (
( + fullUrl, + async (url: string) => { + const sessionRes = await fetcher(url, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + data: { route } + }); + + if (isErrResponse(sessionRes)) { + throw new Error(sessionRes.message || "Failed to get captcha session"); + } + + const { g, n, t, id } = sessionRes; + if (!g || !n || !t || !id) { + throw new Error("Missing required CAPTCHA parameters"); + } + + const ans = await computeVdfInWorker(BigInt(g), BigInt(n), BigInt(t)); + + const resultUrl = new URL(`${backendURL}/captcha/${id}/result`); + resultUrl.searchParams.set("ans", ans.result.toString()); + + const result = await fetcher(resultUrl.toString()); + return result; + } + ); + + return { + startCaptcha: trigger, + captchaResult: data, + isLoadingCaptcha: isMutating, + captchaError: error + }; +} diff --git a/packages/next/components/utils/LocalizedRichText.tsx b/packages/next/components/utils/LocalizedRichText.tsx new file mode 100644 index 0000000..8a28df7 --- /dev/null +++ b/packages/next/components/utils/LocalizedRichText.tsx @@ -0,0 +1,20 @@ +import { ReactNode } from "react"; + +// These tags are available +type Tag = "p" | "b" | "i"; + +type Props = { + children(tags: Record ReactNode>): ReactNode; +}; + +export default function LocalizedRichText({ children }: Props) { + return ( +
+ {children({ + p: (chunks: ReactNode) =>

{chunks}

, + b: (chunks: ReactNode) => {chunks}, + i: (chunks: ReactNode) => {chunks} + })} +
+ ); +} diff --git a/packages/next/i18n/strings/zh.json b/packages/next/i18n/strings/zh.json index 9b915b2..cf13140 100644 --- a/packages/next/i18n/strings/zh.json +++ b/packages/next/i18n/strings/zh.json @@ -6,5 +6,12 @@ }, "username": "用户名", "password": "密码", - "nickname": "昵称" + "nickname": "昵称", + "backend": { + "error": { + "user_exists": "用户名 “{username}” 已被占用。", + "user_not_found_after_register": "我们的服务器出现错误:找不到名为'{username}'的用户。请联系我们的支持团队,反馈此问题。", + "captcha_not_found": "无法完成安全验证。你可以刷新页面并重试。如果此问题反复出现,你可以联系我们的支持团队,反馈此问题。" + } + } } diff --git a/packages/next/lib/net.ts b/packages/next/lib/net.ts index 995a7e2..bcc8b91 100644 --- a/packages/next/lib/net.ts +++ b/packages/next/lib/net.ts @@ -1,3 +1,5 @@ +import axios, { AxiosRequestConfig, AxiosError, Method } from "axios"; + export class ApiRequestError extends Error { public code: number | undefined; public response: unknown | undefined; @@ -8,3 +10,48 @@ export class ApiRequestError extends Error { this.response = res; } } + +type HttpMethod = Extract; + +const httpMethods = { + get: axios.get, + post: axios.post, + put: axios.put, + delete: axios.delete, + patch: axios.patch +}; + +export async function fetcher( + url: string, + init?: Omit & { method?: HttpMethod } +): Promise { + const { method = "get", data, ...config } = init || {}; + + const fullConfig: AxiosRequestConfig = { + method, + ...config, + timeout: 10000 + }; + + try { + const m = method.toLowerCase() as keyof typeof httpMethods; + if (["post", "patch", "put"].includes(m)) { + const response = await httpMethods[m](url, data, fullConfig); + return response.data; + } else { + const response = await httpMethods[m](url, fullConfig); + return response.data; + } + } catch (error) { + const axiosError = error as AxiosError; + + if (axiosError.response) { + const { status, data } = axiosError.response; + throw new ApiRequestError(`HTTP error! status: ${status}`, data, status); + } else if (axiosError.request) { + throw new ApiRequestError("No response received."); + } else { + throw new ApiRequestError(axiosError.message || "Unknown error"); + } + } +} diff --git a/packages/next/next.config.ts b/packages/next/next.config.ts index 18b7ec9..b6f91ef 100644 --- a/packages/next/next.config.ts +++ b/packages/next/next.config.ts @@ -2,7 +2,11 @@ import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; const nextConfig: NextConfig = { - devIndicators: false + devIndicators: false, + experimental: { + externalDir: true + }, + transpilePackages: ["@cvsa/backend"] }; const withNextIntl = createNextIntlPlugin(); diff --git a/packages/next/package.json b/packages/next/package.json index e97702c..85c6298 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -10,6 +10,7 @@ "format": "prettier --write ." }, "dependencies": { + "axios": "^1.9.0", "framer-motion": "^12.12.2", "i18next": "^25.2.1", "next": "^15.1.8", diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json index 96f8e1b..5ce2437 100644 --- a/packages/next/tsconfig.json +++ b/packages/next/tsconfig.json @@ -19,7 +19,8 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "@backend/*": ["../backend/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],