From f4e82092eb03929b6642c197f4df61c08c988de5 Mon Sep 17 00:00:00 2001 From: nyne Date: Thu, 15 May 2025 15:01:39 +0800 Subject: [PATCH] Use cloudflare turnstile. --- frontend/package-lock.json | 11 ++++++ frontend/package.json | 1 + frontend/src/network/network.ts | 9 +++-- frontend/src/pages/register_page.tsx | 28 +++++++++++--- frontend/src/pages/resource_details_page.tsx | 30 ++++++++++++--- server/api/file.go | 3 +- server/api/user.go | 3 +- server/config/config.go | 10 +++-- server/service/file.go | 11 +++++- server/service/user.go | 11 +++++- server/service/utils.go | 40 +++++++++++++++++++- 11 files changed, 134 insertions(+), 23 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e4ae00f..2dca2d6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@marsidev/react-turnstile": "^1.1.0", "@tailwindcss/vite": "^4.1.5", "axios": "^1.9.0", "i18next": "^25.1.1", @@ -1038,6 +1039,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@marsidev/react-turnstile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.1.0.tgz", + "integrity": "sha512-X7bP9ZYutDd+E+klPYF+/BJHqEyyVkN4KKmZcNRr84zs3DcMoftlMAuoKqNSnqg0HE7NQ1844+TLFSJoztCdSA==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.2 || ^18.0.0 || ^19.0", + "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7262adf..263362f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@marsidev/react-turnstile": "^1.1.0", "@tailwindcss/vite": "^4.1.5", "axios": "^1.9.0", "i18next": "^25.1.1", diff --git a/frontend/src/network/network.ts b/frontend/src/network/network.ts index b9d9ca9..0814946 100644 --- a/frontend/src/network/network.ts +++ b/frontend/src/network/network.ts @@ -74,11 +74,12 @@ class Network { } } - async register(username: string, password: string): Promise> { + async register(username: string, password: string, cfToken: string): Promise> { try { const response = await axios.postForm(`${this.apiBaseUrl}/user/register`, { username, - password + password, + cf_token: cfToken }) return response.data } catch (e: any) { @@ -599,8 +600,8 @@ class Network { } } - getFileDownloadLink(fileId: string): string { - return `${this.apiBaseUrl}/files/download/${fileId}`; + getFileDownloadLink(fileId: string, cfToken: string): string { + return `${this.apiBaseUrl}/files/download/${fileId}?cf_token=${cfToken}`; } async createComment(resourceID: number, content: string): Promise> { diff --git a/frontend/src/pages/register_page.tsx b/frontend/src/pages/register_page.tsx index a509ecd..31bb948 100644 --- a/frontend/src/pages/register_page.tsx +++ b/frontend/src/pages/register_page.tsx @@ -3,6 +3,7 @@ import {network} from "../network/network.ts"; import {app} from "../app.ts"; import {useNavigate} from "react-router"; import {useTranslation} from "react-i18next"; +import {Turnstile} from "@marsidev/react-turnstile"; export default function RegisterPage() { const {t} = useTranslation(); @@ -11,11 +12,17 @@ export default function RegisterPage() { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [cfToken, setCfToken] = useState(""); const navigate = useNavigate(); const onSubmit = async (e: FormEvent) => { e.preventDefault(); + setError(null); + if (app.cloudflareTurnstileSiteKey && !cfToken) { + setError(t("Please complete the captcha")); + return; + } if (!username || !password) { setError(t("Username and password cannot be empty")); return; @@ -25,7 +32,7 @@ export default function RegisterPage() { return; } setLoading(true); - const res = await network.register(username, password); + const res = await network.register(username, password, cfToken); if (res.success) { app.user = res.data!; app.token = res.data!.token; @@ -37,9 +44,9 @@ export default function RegisterPage() { } }; - useEffect(() => { - document.title = t("Register"); - }, []) + useEffect(() => { + document.title = t("Register"); + }, [t]) return
@@ -60,12 +67,21 @@ export default function RegisterPage() {
{t("Password")} - setPassword(e.target.value)}/> + setPassword(e.target.value)}/>
{t("Confirm Password")} - setConfirmPassword(e.target.value)}/> + setConfirmPassword(e.target.value)}/>
+ { + app.cloudflareTurnstileSiteKey && setCfToken("")} + /> + } @@ -173,6 +181,18 @@ function FileTile({ file }: { file: RFile }) {
} +function CloudflarePopup({ file }: { file: RFile }) { + const closePopup = useClosePopup() + + return
+ { + closePopup(); + const link = network.getFileDownloadLink(file.id, token); + window.open(link, "_blank"); + }}> +
+} + function Files({ files, resourceID }: { files: RFile[], resourceID: number }) { return
{ diff --git a/server/api/file.go b/server/api/file.go index d3576e0..fd541eb 100644 --- a/server/api/file.go +++ b/server/api/file.go @@ -190,8 +190,9 @@ func deleteFile(c fiber.Ctx) error { } func downloadFile(c fiber.Ctx) error { + cfToken := c.Query("cf_token") ip := c.IP() - s, filename, err := service.DownloadFile(ip, c.Params("id")) + s, filename, err := service.DownloadFile(ip, c.Params("id"), cfToken) if err != nil { return err } diff --git a/server/api/user.go b/server/api/user.go index d7b6ca2..f86ba5a 100644 --- a/server/api/user.go +++ b/server/api/user.go @@ -13,10 +13,11 @@ import ( func handleUserRegister(c fiber.Ctx) error { username := c.FormValue("username") password := c.FormValue("password") + cfToken := c.FormValue("cf_token") if username == "" || password == "" { return model.NewRequestError("Username and password are required") } - user, err := service.CreateUser(username, password) + user, err := service.CreateUser(username, password, cfToken) if err != nil { return err } diff --git a/server/config/config.go b/server/config/config.go index 1852657..35a8653 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -29,8 +29,8 @@ type ServerConfig struct { } func init() { - filepath := filepath.Join(utils.GetStoragePath(), "config.json") - if _, err := os.Stat(filepath); os.IsNotExist(err) { + p := filepath.Join(utils.GetStoragePath(), "config.json") + if _, err := os.Stat(p); os.IsNotExist(err) { config = &ServerConfig{ MaxUploadingSizeInMB: 20 * 1024, // 20GB MaxFileSizeInMB: 8 * 1024, // 8GB @@ -42,7 +42,7 @@ func init() { ServerDescription: "Nysoure is a file sharing service.", } } else { - data, err := os.ReadFile(filepath) + data, err := os.ReadFile(p) if err != nil { panic(err) } @@ -96,3 +96,7 @@ func ServerName() string { func ServerDescription() string { return config.ServerDescription } + +func CloudflareTurnstileSecretKey() string { + return config.CloudflareTurnstileSecretKey +} diff --git a/server/service/file.go b/server/service/file.go index 98437c6..2768c58 100644 --- a/server/service/file.go +++ b/server/service/file.go @@ -385,7 +385,16 @@ func GetFile(fid string) (*model.FileView, error) { return file.ToView(), nil } -func DownloadFile(ip string, fid string) (string, string, error) { +func DownloadFile(ip, fid, cfToken string) (string, string, error) { + passed, err := verifyCfToken(cfToken) + if err != nil { + log.Error("failed to verify cf token: ", err) + return "", "", model.NewRequestError("failed to verify cf token") + } + if !passed { + log.Info("cf token verification failed") + return "", "", model.NewRequestError("cf token verification failed") + } downloads, _ := ipDownloads.Load(ip) if downloads == nil { ipDownloads.Store(ip, 1) diff --git a/server/service/user.go b/server/service/user.go index 54511f4..c84ab01 100644 --- a/server/service/user.go +++ b/server/service/user.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "github.com/gofiber/fiber/v3/log" "nysoure/server/config" "nysoure/server/dao" "nysoure/server/model" @@ -18,7 +19,7 @@ const ( embedAvatarCount = 1 ) -func CreateUser(username, password string) (model.UserViewWithToken, error) { +func CreateUser(username, password, cfToken string) (model.UserViewWithToken, error) { if !config.AllowRegister() { return model.UserViewWithToken{}, model.NewRequestError("User registration is not allowed") } @@ -28,6 +29,14 @@ func CreateUser(username, password string) (model.UserViewWithToken, error) { if len(password) < 6 || len(password) > 20 { return model.UserViewWithToken{}, model.NewRequestError("Password must be between 6 and 20 characters") } + passed, err := verifyCfToken(cfToken) + if err != nil { + log.Error("Error verifying Cloudflare token:", err) + return model.UserViewWithToken{}, model.NewInternalServerError("Failed to verify Cloudflare token") + } + if !passed { + return model.UserViewWithToken{}, model.NewRequestError("invalid Cloudflare token") + } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return model.UserViewWithToken{}, err diff --git a/server/service/utils.go b/server/service/utils.go index b1dc009..d93cc37 100644 --- a/server/service/utils.go +++ b/server/service/utils.go @@ -1,6 +1,12 @@ package service -import "nysoure/server/dao" +import ( + "bytes" + "encoding/json" + "net/http" + "nysoure/server/config" + "nysoure/server/dao" +) func checkUserCanUpload(uid uint) (bool, error) { user, err := dao.GetUserByID(uid) @@ -17,3 +23,35 @@ func CheckUserIsAdmin(uid uint) (bool, error) { } return user.IsAdmin, nil } + +func verifyCfToken(cfToken string) (bool, error) { + if config.CloudflareTurnstileSecretKey() == "" { + return true, nil + } + client := &http.Client{} + data, _ := json.Marshal(map[string]string{ + "secret": config.CloudflareTurnstileSecretKey(), + "response": cfToken, + }) + reader := bytes.NewReader(data) + resp, err := client.Post("https://challenges.cloudflare.com/turnstile/v0/siteverify", "application/json", reader) + if err != nil { + return false, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, nil + } + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, err + } + if result["success"] == nil { + return false, nil + } + if result["success"].(bool) { + return true, nil + } else { + return false, nil + } +}