mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 12:17:24 +00:00
Use cloudflare turnstile.
This commit is contained in:
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
"@tailwindcss/vite": "^4.1.5",
|
"@tailwindcss/vite": "^4.1.5",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"i18next": "^25.1.1",
|
"i18next": "^25.1.1",
|
||||||
@@ -1038,6 +1039,16 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.11.0",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz",
|
||||||
|
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
"@tailwindcss/vite": "^4.1.5",
|
"@tailwindcss/vite": "^4.1.5",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"i18next": "^25.1.1",
|
"i18next": "^25.1.1",
|
||||||
|
@@ -74,11 +74,12 @@ class Network {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async register(username: string, password: string): Promise<Response<UserWithToken>> {
|
async register(username: string, password: string, cfToken: string): Promise<Response<UserWithToken>> {
|
||||||
try {
|
try {
|
||||||
const response = await axios.postForm(`${this.apiBaseUrl}/user/register`, {
|
const response = await axios.postForm(`${this.apiBaseUrl}/user/register`, {
|
||||||
username,
|
username,
|
||||||
password
|
password,
|
||||||
|
cf_token: cfToken
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -599,8 +600,8 @@ class Network {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileDownloadLink(fileId: string): string {
|
getFileDownloadLink(fileId: string, cfToken: string): string {
|
||||||
return `${this.apiBaseUrl}/files/download/${fileId}`;
|
return `${this.apiBaseUrl}/files/download/${fileId}?cf_token=${cfToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createComment(resourceID: number, content: string): Promise<Response<any>> {
|
async createComment(resourceID: number, content: string): Promise<Response<any>> {
|
||||||
|
@@ -3,6 +3,7 @@ import {network} from "../network/network.ts";
|
|||||||
import {app} from "../app.ts";
|
import {app} from "../app.ts";
|
||||||
import {useNavigate} from "react-router";
|
import {useNavigate} from "react-router";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import {Turnstile} from "@marsidev/react-turnstile";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
@@ -11,11 +12,17 @@ export default function RegisterPage() {
|
|||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [cfToken, setCfToken] = useState("");
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (app.cloudflareTurnstileSiteKey && !cfToken) {
|
||||||
|
setError(t("Please complete the captcha"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
setError(t("Username and password cannot be empty"));
|
setError(t("Username and password cannot be empty"));
|
||||||
return;
|
return;
|
||||||
@@ -25,7 +32,7 @@ export default function RegisterPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await network.register(username, password);
|
const res = await network.register(username, password, cfToken);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
app.user = res.data!;
|
app.user = res.data!;
|
||||||
app.token = res.data!.token;
|
app.token = res.data!.token;
|
||||||
@@ -37,9 +44,9 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("Register");
|
document.title = t("Register");
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
return <div className={"flex items-center justify-center w-full h-full bg-base-200"} id={"register-page"}>
|
return <div className={"flex items-center justify-center w-full h-full bg-base-200"} id={"register-page"}>
|
||||||
<div className={"w-96 card card-border bg-base-100 border-base-300"}>
|
<div className={"w-96 card card-border bg-base-100 border-base-300"}>
|
||||||
@@ -60,12 +67,21 @@ export default function RegisterPage() {
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset className="fieldset w-full">
|
<fieldset className="fieldset w-full">
|
||||||
<legend className="fieldset-legend">{t("Password")}</legend>
|
<legend className="fieldset-legend">{t("Password")}</legend>
|
||||||
<input type="password" className="input w-full" value={password} onChange={(e) => setPassword(e.target.value)}/>
|
<input type="password" className="input w-full" value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset className="fieldset w-full">
|
<fieldset className="fieldset w-full">
|
||||||
<legend className="fieldset-legend">{t("Confirm Password")}</legend>
|
<legend className="fieldset-legend">{t("Confirm Password")}</legend>
|
||||||
<input type="password" className="input w-full" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)}/>
|
<input type="password" className="input w-full" value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
{
|
||||||
|
app.cloudflareTurnstileSiteKey && <Turnstile
|
||||||
|
siteKey={app.cloudflareTurnstileSiteKey}
|
||||||
|
onSuccess={setCfToken}
|
||||||
|
onExpire={() => setCfToken("")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
<button className={"btn my-4 btn-primary"} type={"submit"}>
|
<button className={"btn my-4 btn-primary"} type={"submit"}>
|
||||||
{isLoading && <span className="loading loading-spinner"></span>}
|
{isLoading && <span className="loading loading-spinner"></span>}
|
||||||
{t("Continue")}
|
{t("Continue")}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
import {createContext, createRef, useCallback, useContext, useEffect, useRef, useState} from "react";
|
||||||
import { ResourceDetails, RFile, Storage, Comment } from "../network/models.ts";
|
import { ResourceDetails, RFile, Storage, Comment } from "../network/models.ts";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import showToast from "../components/toast.ts";
|
import showToast from "../components/toast.ts";
|
||||||
@@ -12,6 +12,8 @@ import { uploadingManager } from "../network/uploading.ts";
|
|||||||
import { ErrorAlert } from "../components/alert.tsx";
|
import { ErrorAlert } from "../components/alert.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Pagination from "../components/pagination.tsx";
|
import Pagination from "../components/pagination.tsx";
|
||||||
|
import showPopup, {useClosePopup} from "../components/popup.tsx";
|
||||||
|
import {Turnstile} from "@marsidev/react-turnstile";
|
||||||
|
|
||||||
export default function ResourcePage() {
|
export default function ResourcePage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -39,7 +41,7 @@ export default function ResourcePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("Resource Details");
|
document.title = t("Resource Details");
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isNaN(id)) {
|
if (!isNaN(id)) {
|
||||||
@@ -155,6 +157,8 @@ function Article({ article }: { article: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FileTile({ file }: { file: RFile }) {
|
function FileTile({ file }: { file: RFile }) {
|
||||||
|
const buttonRef = createRef<HTMLButtonElement>()
|
||||||
|
|
||||||
return <div className={"card card-border border-base-300 my-2"}>
|
return <div className={"card card-border border-base-300 my-2"}>
|
||||||
<div className={"p-4 flex flex-row items-center"}>
|
<div className={"p-4 flex flex-row items-center"}>
|
||||||
<div className={"grow"}>
|
<div className={"grow"}>
|
||||||
@@ -162,9 +166,13 @@ function FileTile({ file }: { file: RFile }) {
|
|||||||
<p className={"text-sm"}>{file.description}</p>
|
<p className={"text-sm"}>{file.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button className={"btn btn-primary btn-soft btn-square"} onClick={() => {
|
<button ref={buttonRef} className={"btn btn-primary btn-soft btn-square"} onClick={() => {
|
||||||
const link = network.getFileDownloadLink(file.id);
|
if (!app.cloudflareTurnstileSiteKey) {
|
||||||
window.open(link, "_blank");
|
const link = network.getFileDownloadLink(file.id, "");
|
||||||
|
window.open(link, "_blank");
|
||||||
|
} else {
|
||||||
|
showPopup(<CloudflarePopup file={file}/>, buttonRef.current!)
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
<MdOutlineDownload size={24} />
|
<MdOutlineDownload size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -173,6 +181,18 @@ function FileTile({ file }: { file: RFile }) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CloudflarePopup({ file }: { file: RFile }) {
|
||||||
|
const closePopup = useClosePopup()
|
||||||
|
|
||||||
|
return <div className={"menu bg-base-100 rounded-box z-1 w-80 p-2 shadow-sm h-20"}>
|
||||||
|
<Turnstile siteKey={app.cloudflareTurnstileSiteKey!} onSuccess={(token) => {
|
||||||
|
closePopup();
|
||||||
|
const link = network.getFileDownloadLink(file.id, token);
|
||||||
|
window.open(link, "_blank");
|
||||||
|
}}></Turnstile>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
function Files({ files, resourceID }: { files: RFile[], resourceID: number }) {
|
function Files({ files, resourceID }: { files: RFile[], resourceID: number }) {
|
||||||
return <div>
|
return <div>
|
||||||
{
|
{
|
||||||
|
@@ -190,8 +190,9 @@ func deleteFile(c fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func downloadFile(c fiber.Ctx) error {
|
func downloadFile(c fiber.Ctx) error {
|
||||||
|
cfToken := c.Query("cf_token")
|
||||||
ip := c.IP()
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@@ -13,10 +13,11 @@ import (
|
|||||||
func handleUserRegister(c fiber.Ctx) error {
|
func handleUserRegister(c fiber.Ctx) error {
|
||||||
username := c.FormValue("username")
|
username := c.FormValue("username")
|
||||||
password := c.FormValue("password")
|
password := c.FormValue("password")
|
||||||
|
cfToken := c.FormValue("cf_token")
|
||||||
if username == "" || password == "" {
|
if username == "" || password == "" {
|
||||||
return model.NewRequestError("Username and password are required")
|
return model.NewRequestError("Username and password are required")
|
||||||
}
|
}
|
||||||
user, err := service.CreateUser(username, password)
|
user, err := service.CreateUser(username, password, cfToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@@ -29,8 +29,8 @@ type ServerConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
filepath := filepath.Join(utils.GetStoragePath(), "config.json")
|
p := filepath.Join(utils.GetStoragePath(), "config.json")
|
||||||
if _, err := os.Stat(filepath); os.IsNotExist(err) {
|
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||||
config = &ServerConfig{
|
config = &ServerConfig{
|
||||||
MaxUploadingSizeInMB: 20 * 1024, // 20GB
|
MaxUploadingSizeInMB: 20 * 1024, // 20GB
|
||||||
MaxFileSizeInMB: 8 * 1024, // 8GB
|
MaxFileSizeInMB: 8 * 1024, // 8GB
|
||||||
@@ -42,7 +42,7 @@ func init() {
|
|||||||
ServerDescription: "Nysoure is a file sharing service.",
|
ServerDescription: "Nysoure is a file sharing service.",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
data, err := os.ReadFile(filepath)
|
data, err := os.ReadFile(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -96,3 +96,7 @@ func ServerName() string {
|
|||||||
func ServerDescription() string {
|
func ServerDescription() string {
|
||||||
return config.ServerDescription
|
return config.ServerDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CloudflareTurnstileSecretKey() string {
|
||||||
|
return config.CloudflareTurnstileSecretKey
|
||||||
|
}
|
||||||
|
@@ -385,7 +385,16 @@ func GetFile(fid string) (*model.FileView, error) {
|
|||||||
return file.ToView(), nil
|
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)
|
downloads, _ := ipDownloads.Load(ip)
|
||||||
if downloads == nil {
|
if downloads == nil {
|
||||||
ipDownloads.Store(ip, 1)
|
ipDownloads.Store(ip, 1)
|
||||||
|
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/gofiber/fiber/v3/log"
|
||||||
"nysoure/server/config"
|
"nysoure/server/config"
|
||||||
"nysoure/server/dao"
|
"nysoure/server/dao"
|
||||||
"nysoure/server/model"
|
"nysoure/server/model"
|
||||||
@@ -18,7 +19,7 @@ const (
|
|||||||
embedAvatarCount = 1
|
embedAvatarCount = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateUser(username, password string) (model.UserViewWithToken, error) {
|
func CreateUser(username, password, cfToken string) (model.UserViewWithToken, error) {
|
||||||
if !config.AllowRegister() {
|
if !config.AllowRegister() {
|
||||||
return model.UserViewWithToken{}, model.NewRequestError("User registration is not allowed")
|
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 {
|
if len(password) < 6 || len(password) > 20 {
|
||||||
return model.UserViewWithToken{}, model.NewRequestError("Password must be between 6 and 20 characters")
|
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)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.UserViewWithToken{}, err
|
return model.UserViewWithToken{}, err
|
||||||
|
@@ -1,6 +1,12 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "nysoure/server/dao"
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"nysoure/server/config"
|
||||||
|
"nysoure/server/dao"
|
||||||
|
)
|
||||||
|
|
||||||
func checkUserCanUpload(uid uint) (bool, error) {
|
func checkUserCanUpload(uid uint) (bool, error) {
|
||||||
user, err := dao.GetUserByID(uid)
|
user, err := dao.GetUserByID(uid)
|
||||||
@@ -17,3 +23,35 @@ func CheckUserIsAdmin(uid uint) (bool, error) {
|
|||||||
}
|
}
|
||||||
return user.IsAdmin, nil
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user