diff --git a/.env b/.env
index 245a102..820e639 100644
--- a/.env
+++ b/.env
@@ -1,2 +1,4 @@
VITE_R2_BASE_URL=https://pub-e115c4e749734702abd09206cba74257.r2.dev/
VITE_HCAPTCHA_SITEKEY=a7340f48-b55e-4c56-8d96-2e70ce3423e0
+VITE_RECAPTCHA_SITEKEY=6LdTSxkrAAAAAOWt1LWFd9HYt8IRXyT0PaJXouC3
+VITE_TURNSTILE_SITEKEY=0x4AAAAAABL64iQLO7IcpeAL
\ No newline at end of file
diff --git a/functions/api/avatars/index.js b/functions/api/avatars/index.js
index 8552635..95d0244 100644
--- a/functions/api/avatars/index.js
+++ b/functions/api/avatars/index.js
@@ -1,19 +1,10 @@
import { verifyJWT } from '../../middleware/auth';
+import { captchaPlugins } from '../../middleware/captcha';
import { createErrorResponse, createSuccessResponse } from '../../utils';
import { fileTypeFromBuffer } from 'file-type';
-import hCaptchaPlugin from "@cloudflare/pages-plugin-hcaptcha";
export const onRequestPut = [
- async (context) => {
- return hCaptchaPlugin({
- secret: context.env.hcaptcha_secret_key,
- sitekey: context.env.hcaptcha_site_key,
- onError: (context) => {
- console.error("hCaptcha error:", context.error);
- return createErrorResponse("hCaptcha verification failed", 403);
- }
- })(context);
- },
+ ...captchaPlugins,
async (context) => {
const { request, env } = context;
diff --git a/functions/api/login.js b/functions/api/login.js
index f8b43de..32187b8 100644
--- a/functions/api/login.js
+++ b/functions/api/login.js
@@ -1,18 +1,9 @@
import { SignJWT } from 'jose';
import { createSuccessResponse, createErrorResponse } from "../utils";
-import hCaptchaPlugin from "@cloudflare/pages-plugin-hcaptcha";
+import { captchaPlugins } from '../middleware/captcha';
export const onRequestPost = [
- async (context) => {
- return hCaptchaPlugin({
- secret: context.env.hcaptcha_secret_key,
- sitekey: context.env.hcaptcha_site_key,
- onError: (context) => {
- console.error("hCaptcha error:", context.error);
- return createErrorResponse("hCaptcha verification failed", 403);
- }
- })(context);
- },
+ ...captchaPlugins,
async (context) => {
try {
const { request, env } = context;
diff --git a/functions/api/messages.js b/functions/api/messages.js
index 14aa798..451d25c 100644
--- a/functions/api/messages.js
+++ b/functions/api/messages.js
@@ -1,6 +1,6 @@
import { verifyJWT } from '../middleware/auth';
+import { captchaPlugins } from '../middleware/captcha';
import { createErrorResponse, createSuccessResponse } from '../utils';
-import hCaptchaPlugin from "@cloudflare/pages-plugin-hcaptcha";
export async function onRequestGet(context) {
try {
@@ -17,16 +17,7 @@ export async function onRequestGet(context) {
}
export const onRequestPost = [
- async (context) => {
- return hCaptchaPlugin({
- secret: context.env.hcaptcha_secret_key,
- sitekey: context.env.hcaptcha_site_key,
- onError: (context) => {
- console.error("hCaptcha error:", context.error);
- return createErrorResponse("hCaptcha verification failed", 403);
- }
- })(context);
- },
+ ...captchaPlugins,
async (context) => {
try {
const { request, env } = context;
diff --git a/functions/api/motto.js b/functions/api/motto.js
index 5df5835..18a062d 100644
--- a/functions/api/motto.js
+++ b/functions/api/motto.js
@@ -1,18 +1,9 @@
import { verifyJWT } from '../middleware/auth';
import { createErrorResponse, createSuccessResponse } from '../utils';
-import hCaptchaPlugin from "@cloudflare/pages-plugin-hcaptcha";
+import { captchaPlugins } from '../middleware/captcha';
export const onRequestPost = [
- async (context) => {
- return hCaptchaPlugin({
- secret: context.env.hcaptcha_secret_key,
- sitekey: context.env.hcaptcha_site_key,
- onError: (context) => {
- console.error("hCaptcha error:", context.error);
- return createErrorResponse("hCaptcha verification failed", 403);
- }
- })(context);
- },
+ ...captchaPlugins,
async (context) => {
try {
// Verify the JWT token
diff --git a/functions/api/register.js b/functions/api/register.js
index fd97646..65aa44e 100644
--- a/functions/api/register.js
+++ b/functions/api/register.js
@@ -1,17 +1,8 @@
+import { captchaPlugins } from '../middleware/captcha';
import { createErrorResponse, createSuccessResponse } from '../utils';
-import hCaptchaPlugin from "@cloudflare/pages-plugin-hcaptcha";
export const onRequestPost = [
- async (context) => {
- return hCaptchaPlugin({
- secret: context.env.hcaptcha_secret_key,
- sitekey: context.env.hcaptcha_site_key,
- onError: (context) => {
- console.error("hCaptcha error:", context.error);
- return createErrorResponse("hCaptcha verification failed", 403);
- }
- })(context);
- },
+ ...captchaPlugins,
async (context) => {
try {
const { request, env } = context;
diff --git a/functions/middleware/captcha.js b/functions/middleware/captcha.js
new file mode 100644
index 0000000..4f86702
--- /dev/null
+++ b/functions/middleware/captcha.js
@@ -0,0 +1,58 @@
+import { createErrorResponse } from '../utils';
+
+import hCaptchaPlugin from "@cloudflare/pages-plugin-hcaptcha";
+import turnstilePlugin from "@cloudflare/pages-plugin-turnstile";
+
+export const captchaPlugins = [
+ async (context) => {
+ try {
+ return hCaptchaPlugin({
+ secret: context.env.hcaptcha_secret_key,
+ sitekey: context.env.hcaptcha_site_key,
+ onError: (context) => {
+ console.error("hCaptcha error:", context.error);
+ return createErrorResponse("hCaptcha verification failed", 403);
+ }
+ })(context);
+ } catch (e) {
+ console.error("hCaptcha error:", e);
+ return createErrorResponse("hCaptcha verification failed", 400);
+ }
+ },
+ async (context) => {
+ try {
+ const recaptchaResponse = (await context.request.clone().formData()).get("g-recaptcha-response").toString();
+ const formData = new FormData();
+ formData.append("secret", context.env.recaptcha_secret_key);
+ formData.append("response", recaptchaResponse);
+
+ const response = await fetch("https://www.google.com/recaptcha/api/siteverify", {
+ method: "POST",
+ body: formData
+ });
+ const data = await response.json();
+ if (!data.success) {
+ console.error("reCAPTCHA error:", data);
+ return createErrorResponse("reCAPTCHA verification failed", 403);
+ }
+ } catch (e) {
+ console.error("reCAPTCHA error:", e);
+ return createErrorResponse("reCAPTCHA verification failed", 400);
+ }
+ return context.next();
+ },
+ async (context) => {
+ try {
+ return turnstilePlugin({
+ secret: context.env.turnstile_secret_key,
+ onError: (context) => {
+ console.error("Turnstile error:", context.error);
+ return createErrorResponse("Turnstile verification failed", 403);
+ }
+ })(context)
+ } catch (e) {
+ console.error("Turnstile error:", e);
+ return createErrorResponse("Turnstile verification failed", 400);
+ }
+ }
+]
\ No newline at end of file
diff --git a/index.html b/index.html
index a33c0ac..0eb11e1 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,7 @@
-
+
Practicum of Attacking and Defense of Network Security
diff --git a/package-lock.json b/package-lock.json
index 3560a09..b1ff965 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,12 +9,15 @@
"version": "0.0.0",
"dependencies": {
"@cloudflare/pages-plugin-hcaptcha": "^1.0.4",
+ "@cloudflare/pages-plugin-turnstile": "^1.0.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"file-type": "^20.4.1",
"jose": "^6.0.10",
"pinia": "^3.0.2",
"vue": "^3.5.13",
- "vue-router": "^4.5.0"
+ "vue-router": "^4.5.0",
+ "vue-turnstile": "^1.0.11",
+ "vue3-recaptcha-v2": "^2.1.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250327.0",
@@ -75,6 +78,12 @@
"integrity": "sha512-LZ1kWAhj3/wdAAnJs/fUG8akC+FKizLu2AdhVzr4aExncXA2wjXvphktG40pvEMIoMXH5LBqP9H7YNguR14Y7Q==",
"license": "MIT"
},
+ "node_modules/@cloudflare/pages-plugin-turnstile": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@cloudflare/pages-plugin-turnstile/-/pages-plugin-turnstile-1.0.2.tgz",
+ "integrity": "sha512-vKPqN/guV1sk/t8TUIZEGlrlld3iwmfdbuWex8jbhUmv/9ozf2HmBjVBYgnr5q7UPjQsWB7hwqZsx8z3k+BkyQ==",
+ "license": "MIT"
+ },
"node_modules/@cloudflare/workers-types": {
"version": "4.20250412.0",
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250412.0.tgz",
@@ -1917,6 +1926,24 @@
"peerDependencies": {
"vue": "^3.2.0"
}
+ },
+ "node_modules/vue-turnstile": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/vue-turnstile/-/vue-turnstile-1.0.11.tgz",
+ "integrity": "sha512-iaTBoZ5oUqtNRto6bmbn6FQvW0h/sK7mPUJc1Qn4em+cELXN59U2FQTcpWfKssV3OY6lEZzmCpcn/zrb7htK3A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "vue": "^3.2.45"
+ }
+ },
+ "node_modules/vue3-recaptcha-v2": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/vue3-recaptcha-v2/-/vue3-recaptcha-v2-2.1.0.tgz",
+ "integrity": "sha512-dy1qieyWkRHR0yfuHaiI4aPKAsDJ/9Gwl58bl7gU9UtDMOfFAAmimMbyuYTRdxNU90dapJ5LLf2u+2h+gfOiSg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "vue": "^3"
+ }
}
}
}
diff --git a/package.json b/package.json
index f023306..3163555 100644
--- a/package.json
+++ b/package.json
@@ -10,16 +10,21 @@
"preview": "vite preview",
"pages:dev": "wrangler pages dev --proxy 5173",
"pages:deploy": "wrangler pages deploy dist",
- "deploy": "npm run build && npm run pages:deploy"
+ "deploy": "npm run build && npm run pages:deploy",
+ "migrate": "wrangler d1 execute --file schema.sql --local awd-db",
+ "d1:migrate": "wrangler d1 execute --file schema.sql --local awd-db"
},
"dependencies": {
"@cloudflare/pages-plugin-hcaptcha": "^1.0.4",
+ "@cloudflare/pages-plugin-turnstile": "^1.0.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"file-type": "^20.4.1",
"jose": "^6.0.10",
"pinia": "^3.0.2",
"vue": "^3.5.13",
- "vue-router": "^4.5.0"
+ "vue-router": "^4.5.0",
+ "vue-turnstile": "^1.0.11",
+ "vue3-recaptcha-v2": "^2.1.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250327.0",
diff --git a/src/components/Board/BoardForm.vue b/src/components/Board/BoardForm.vue
index cdc77ad..fa37888 100644
--- a/src/components/Board/BoardForm.vue
+++ b/src/components/Board/BoardForm.vue
@@ -1,36 +1,32 @@
@@ -52,15 +48,14 @@ function submit() {
-
-
+
diff --git a/src/components/CAPTCHA.vue b/src/components/CAPTCHA.vue
new file mode 100644
index 0000000..7ae4e70
--- /dev/null
+++ b/src/components/CAPTCHA.vue
@@ -0,0 +1,109 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/Login/LoginForm.vue b/src/components/Login/LoginForm.vue
index 6fced95..01c25af 100644
--- a/src/components/Login/LoginForm.vue
+++ b/src/components/Login/LoginForm.vue
@@ -1,26 +1,20 @@
@@ -92,16 +89,15 @@ const submit = () => {
-
diff --git a/src/components/Profile/ProfileForm.vue b/src/components/Profile/ProfileForm.vue
index 2c53cdd..4f6d8a5 100644
--- a/src/components/Profile/ProfileForm.vue
+++ b/src/components/Profile/ProfileForm.vue
@@ -2,7 +2,7 @@
import { ref, computed, defineProps } from 'vue';
import { useRouter } from 'vue-router';
import { uploadAvatar } from '../../lib/api';
-import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
+import CAPTCHA from '../CAPTCHA.vue';
import { useAuthStore } from '../../stores/auth';
const props = defineProps({
@@ -15,22 +15,20 @@ const props = defineProps({
const authStore = useAuthStore();
const router = useRouter();
-const hcaptchaRef = ref(null);
-
const avatarFile = ref(null);
const avatarError = ref('');
-const hcaptchaResponse = ref('');
+const captchaResponse = ref(null);
const onFileChange = (event) => {
avatarFile.value = event.target.files[0];
};
-const handleHcaptchaVerify = (token) => {
- hcaptchaResponse.value = token;
-};
+const captchaVerified = computed(() => {
+ return captchaResponse.value !== null;
+});
-const handleHcaptchaExpired = () => {
- hcaptchaResponse.value = '';
+const handleCaptchaVerified = (response) => {
+ captchaResponse.value = response;
};
const onSubmit = async () => {
@@ -41,7 +39,7 @@ const onSubmit = async () => {
return;
}
- if (!hcaptchaResponse.value) {
+ if (!captchaVerified) {
avatarError.value = '請完成驗證。';
return;
}
@@ -57,9 +55,8 @@ const onSubmit = async () => {
}
try {
- await uploadAvatar(avatarFile.value, authStore.jwt, hcaptchaResponse.value);
+ await uploadAvatar(avatarFile.value, authStore.jwt, captchaResponse.value);
alert('Avatar uploaded successfully!');
- hcaptchaRef.value.reset();
// After successful upload, reload this page
router.go(0);
} catch (error) {
@@ -103,15 +100,14 @@ const avatarUrl = computed(() => {
-
-
+
diff --git a/src/components/Register/RegisterForm.vue b/src/components/Register/RegisterForm.vue
index 951e0cc..252864a 100644
--- a/src/components/Register/RegisterForm.vue
+++ b/src/components/Register/RegisterForm.vue
@@ -1,25 +1,19 @@
@@ -94,15 +91,14 @@ const submit = () => {
-
-
+
diff --git a/src/lib/api.js b/src/lib/api.js
index 1b1ec15..07f6e0f 100644
--- a/src/lib/api.js
+++ b/src/lib/api.js
@@ -2,7 +2,7 @@ import { unauthRedirectToLogin } from '../router';
const API_BASE_URL = '/api';
-export async function register(username, password, hCaptchaResponse) {
+export async function register(username, password, { hCaptchaResponse, recaptchaResponse, turnstileResponse }) {
const formData = new FormData();
const payload = { username, password };
@@ -10,6 +10,12 @@ export async function register(username, password, hCaptchaResponse) {
if (hCaptchaResponse) {
formData.append('h-captcha-response', hCaptchaResponse);
}
+ if (recaptchaResponse) {
+ formData.append('g-recaptcha-response', recaptchaResponse);
+ }
+ if (turnstileResponse) {
+ formData.append('cf-turnstile-response', turnstileResponse);
+ }
const response = await fetch(API_BASE_URL + "/register", {
method: 'POST',
@@ -24,7 +30,7 @@ export async function register(username, password, hCaptchaResponse) {
return response.json();
}
-export async function login(username, password, hCaptchaResponse) {
+export async function login(username, password, { hCaptchaResponse, recaptchaResponse, turnstileResponse }) {
const formData = new FormData();
const payload = { username, password };
@@ -32,6 +38,12 @@ export async function login(username, password, hCaptchaResponse) {
if (hCaptchaResponse) {
formData.append('h-captcha-response', hCaptchaResponse);
}
+ if (recaptchaResponse) {
+ formData.append('g-recaptcha-response', recaptchaResponse);
+ }
+ if (turnstileResponse) {
+ formData.append('cf-turnstile-response', turnstileResponse);
+ }
const response = await fetch(API_BASE_URL + "/login", {
method: 'POST',
@@ -46,7 +58,7 @@ export async function login(username, password, hCaptchaResponse) {
return response.json();
}
-export async function postMessage(message, jwt, hCaptchaResponse) {
+export async function postMessage(message, jwt, { hCaptchaResponse, recaptchaResponse, turnstileResponse }) {
const formData = new FormData();
const payload = { message };
@@ -54,6 +66,12 @@ export async function postMessage(message, jwt, hCaptchaResponse) {
if (hCaptchaResponse) {
formData.append('h-captcha-response', hCaptchaResponse);
}
+ if (recaptchaResponse) {
+ formData.append('g-recaptcha-response', recaptchaResponse);
+ }
+ if (turnstileResponse) {
+ formData.append('cf-turnstile-response', turnstileResponse);
+ }
const response = await fetch(API_BASE_URL + "/messages", {
method: 'POST',
@@ -138,13 +156,18 @@ export async function getProfile(jwt) {
return response.json();
}
-export async function uploadAvatar(avatar, jwt, hCaptchaResponse) {
+export async function uploadAvatar(avatar, jwt, { hCaptchaResponse, recaptchaResponse, turnstileResponse }) {
const formData = new FormData();
formData.append('avatar', avatar);
-
if (hCaptchaResponse) {
formData.append('h-captcha-response', hCaptchaResponse);
}
+ if (recaptchaResponse) {
+ formData.append('g-recaptcha-response', recaptchaResponse);
+ }
+ if (turnstileResponse) {
+ formData.append('cf-turnstile-response', turnstileResponse);
+ }
const response = await fetch(API_BASE_URL + "/avatars", {
method: 'PUT',
@@ -167,11 +190,17 @@ export async function uploadAvatar(avatar, jwt, hCaptchaResponse) {
return data;
}
-export async function generateMotto(jwt, hCaptchaResponse) {
+export async function generateMotto(jwt, { hCaptchaResponse, recaptchaResponse, turnstileResponse }) {
const formData = new FormData();
if (hCaptchaResponse) {
formData.append('h-captcha-response', hCaptchaResponse);
}
+ if (recaptchaResponse) {
+ formData.append('g-recaptcha-response', recaptchaResponse);
+ }
+ if (turnstileResponse) {
+ formData.append('cf-turnstile-response', turnstileResponse);
+ }
try {
const response = await fetch('/api/motto', {
diff --git a/src/lib/darkMode.js b/src/lib/darkMode.js
new file mode 100644
index 0000000..e8f26da
--- /dev/null
+++ b/src/lib/darkMode.js
@@ -0,0 +1,16 @@
+import { reactive } from 'vue';
+
+const darkModeMediaQuery = '(prefers-color-scheme: dark)';
+const matchMedia = window.matchMedia(darkModeMediaQuery);
+
+const darkMode = reactive({
+ value: matchMedia.matches,
+});
+
+matchMedia.addEventListener('change', (event) => {
+ darkMode.value = event.matches;
+});
+
+export const install = (app) => {
+ app.config.globalProperties.$darkMode = darkMode;
+}
\ No newline at end of file
diff --git a/src/main.js b/src/main.js
index 0c1ba9e..d70cd2e 100644
--- a/src/main.js
+++ b/src/main.js
@@ -3,13 +3,21 @@ import './style.css';
import App from './App.vue';
import router from './router';
import { createPinia } from 'pinia';
+import { install as installRecaptcha } from "vue3-recaptcha-v2";
+import { install as installDarkMode } from './lib/darkMode';
const pinia = createPinia();
const app = createApp(App);
app.use(pinia);
app.use(router);
+app.use(installDarkMode);
+app.use(installRecaptcha, {
+ sitekey: import.meta.env.VITE_RECAPTCHA_SITEKEY
+});
app.config.globalProperties.$hcaptchaSitekey = import.meta.env.VITE_HCAPTCHA_SITEKEY;
+app.config.globalProperties.$recaptchaSitekey = import.meta.env.VITE_RECAPTCHA_SITEKEY;
+app.config.globalProperties.$turnstileSitekey = import.meta.env.VITE_TURNSTILE_SITEKEY;
app.mount('#app');
diff --git a/src/style.css b/src/style.css
index 02f4e0c..6fddec4 100644
--- a/src/style.css
+++ b/src/style.css
@@ -11,5 +11,4 @@ html, body, #app {
display: flex;
align-items: center;
justify-content: center;
- padding: 1em 0;
}
\ No newline at end of file
diff --git a/src/views/DailyMottoView.vue b/src/views/DailyMottoView.vue
index ac0ef62..5506160 100644
--- a/src/views/DailyMottoView.vue
+++ b/src/views/DailyMottoView.vue
@@ -1,32 +1,32 @@