feat: hcaptcha

This commit is contained in:
Tony Yang
2025-04-15 14:46:16 +08:00
parent b8ae97e49b
commit 1b60b3517d
20 changed files with 430 additions and 247 deletions
+1
View File
@@ -1 +1,2 @@
VITE_R2_BASE_URL=https://pub-e115c4e749734702abd09206cba74257.r2.dev/ VITE_R2_BASE_URL=https://pub-e115c4e749734702abd09206cba74257.r2.dev/
VITE_HCAPTCHA_SITEKEY=a7340f48-b55e-4c56-8d96-2e70ce3423e0
+56 -43
View File
@@ -1,50 +1,63 @@
import { verifyJWT } from '../../middleware/auth'; import { verifyJWT } from '../../middleware/auth';
import { createErrorResponse, createSuccessResponse } from '../../utils'; import { createErrorResponse, createSuccessResponse } from '../../utils';
import { fileTypeFromBuffer } from 'file-type'; import { fileTypeFromBuffer } from 'file-type';
import hCaptchaPlugin from "@cloudflare/pages-plugin-hcaptcha";
export async function onRequestPut(context) { export const onRequestPut = [
try { 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);
},
async (context) => {
const { request, env } = context; const { request, env } = context;
// Verify the JWT token try {
const authResult = await verifyJWT(context); // Verify the JWT token
if (authResult) { const authResult = await verifyJWT(context);
return authResult; // Return the error response from the middleware if (authResult) {
return authResult; // Return the error response from the middleware
}
const formData = await request.formData();
const avatar = formData.get('avatar');
if (!avatar) {
return createErrorResponse("Missing avatar", 400);
}
if (avatar.size > 1 * 1024 * 1024) {
// Entity too large
return createErrorResponse("Avatar must be less than 1MB", 413);
}
const buffer = await avatar.arrayBuffer();
const fileTypeResult = await fileTypeFromBuffer(buffer);
if (!fileTypeResult) {
return createErrorResponse("Unsupported file type", 400);
}
if (fileTypeResult.mime !== 'image/jpeg' && fileTypeResult.mime !== 'image/png') {
return createErrorResponse("Avatar must be a JPG or PNG image", 400);
}
// Upload the avatar to R2
const objectName = `avatars/${context.user.userId}`;
await env.MY_BUCKET.put(objectName, buffer);
// Store the filename in D1
await env.DB.prepare("UPDATE users SET avatar = ? WHERE id = ?").bind(objectName, context.user.userId).run();
return createSuccessResponse({ message: "Avatar uploaded successfully" });
} catch (error) {
console.error("Avatar upload error:", error);
return createErrorResponse("Avatar upload failed", 500);
} }
},
const formData = await request.formData(); ];
const avatar = formData.get('avatar');
if (!avatar) {
return createErrorResponse("Missing avatar", 400);
}
if (avatar.size > 1 * 1024 * 1024) {
// Entity too large
return createErrorResponse("Avatar must be less than 1MB", 413);
}
const buffer = await avatar.arrayBuffer();
const fileTypeResult = await fileTypeFromBuffer(buffer);
if (!fileTypeResult) {
return createErrorResponse("Unsupported file type", 400);
}
if (fileTypeResult.mime !== 'image/jpeg' && fileTypeResult.mime !== 'image/png') {
return createErrorResponse("Avatar must be a JPG or PNG image", 400);
}
// Upload the avatar to R2
const objectName = `avatars/${context.user.userId}`;
await env.MY_BUCKET.put(objectName, buffer);
// Store the filename in D1
await env.DB.prepare("UPDATE users SET avatar = ? WHERE id = ?").bind(objectName, context.user.userId).run();
return createSuccessResponse({ message: "Avatar uploaded successfully" });
} catch (error) {
console.error("Avatar upload error:", error);
return createErrorResponse("Avatar upload failed", 500);
}
}
+61 -39
View File
@@ -1,58 +1,80 @@
import { SignJWT } from 'jose'; import { SignJWT } from 'jose';
import { createSuccessResponse, createErrorResponse } from "../utils"; import { createSuccessResponse, createErrorResponse } from "../utils";
import hCaptchaPlugin from "@cloudflare/pages-plugin-hcaptcha";
export async function onRequestPost(context) { export const onRequestPost = [
try { async (context) => {
const { request, env } = 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);
},
async (context) => {
try {
const { request, env } = context;
let payload;
const { username, password } = await request.json(); try {
const formData = await request.formData();
payload = JSON.parse(formData.get('payload'));
} catch (e) {
console.error("Payload parsing error:", e);
return createErrorResponse("Invalid payload", 400);
}
if (!username || !password) { const { username, password } = payload;
return createErrorResponse("Missing username or password", 400);
}
if (username.length < 3) { if (!username || !password) {
return createErrorResponse("Username must be at least 3 characters", 400); return createErrorResponse("Missing username or password", 400);
} }
if (password.length < 8) { if (username.length < 3) {
return createErrorResponse("Password must be at least 8 characters", 400); return createErrorResponse("Username must be at least 3 characters", 400);
} }
if (!/^[a-zA-Z0-9]+$/.test(username)) { if (password.length < 8) {
return createErrorResponse("Username must be alphanumeric", 400); return createErrorResponse("Password must be at least 8 characters", 400);
} }
// Get the stored password from D1 if (!/^[a-zA-Z0-9]+$/.test(username)) {
const { results } = await env.DB.prepare("SELECT password FROM users WHERE username = ?").bind(username).all(); return createErrorResponse("Username must be alphanumeric", 400);
}
if (!results || results.length === 0) { // Get the stored password from D1
return new Response(JSON.stringify({"error": "Invalid username or password"}), { status: 403, headers: { 'Content-Type': 'application/json' } }); const { results } = await env.DB.prepare("SELECT password FROM users WHERE username = ?").bind(username).all();
}
const storedPassword = results[0].password; if (!results || results.length === 0) {
return new Response(JSON.stringify({"error": "Invalid username or password"}), { status: 403, headers: { 'Content-Type': 'application/json' } });
}
// Compare the password to the stored password const storedPassword = results[0].password;
if (password !== storedPassword) {
return new Response(JSON.stringify({"error": "Invalid username or password"}), { status: 403, headers: { 'Content-Type': 'application/json' } });
}
// Get the user ID // Compare the password to the stored password
const { results: userResults } = await env.DB.prepare("SELECT * FROM users WHERE username = ?").bind(username).all(); if (password !== storedPassword) {
const jwtPayload = (({ id, username }) => ({ id, username }))(userResults[0]); return new Response(JSON.stringify({"error": "Invalid username or password"}), { status: 403, headers: { 'Content-Type': 'application/json' } });
}
// Generate a JWT token // Get the user ID
const jwt = await new SignJWT(jwtPayload) const { results: userResults } = await env.DB.prepare("SELECT * FROM users WHERE username = ?").bind(username).all();
.setProtectedHeader({ alg: 'HS256' }) const jwtPayload = (({ id, username }) => ({ id, username }))(userResults[0]);
.setIssuedAt()
.setIssuer('urn:example:issuer')
.setAudience('urn:example:audience')
.setExpirationTime('2h')
.sign(new TextEncoder().encode(env.JWT_SECRET));
return createSuccessResponse({ jwt }); // Generate a JWT token
const jwt = await new SignJWT(jwtPayload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer('urn:example:issuer')
.setAudience('urn:example:audience')
.setExpirationTime('2h')
.sign(new TextEncoder().encode(env.JWT_SECRET));
return createSuccessResponse({ jwt });
} catch (error) { } catch (error) {
console.error("Login error:", error); console.error("Login error:", error);
return createErrorResponse("Login failed", 500); return createErrorResponse("Login failed", 500);
} }
} },
];
+41 -25
View File
@@ -1,5 +1,6 @@
import { verifyJWT } from '../middleware/auth'; import { verifyJWT } from '../middleware/auth';
import { createErrorResponse, createSuccessResponse } from '../utils'; import { createErrorResponse, createSuccessResponse } from '../utils';
import hCaptchaPlugin from "@cloudflare/pages-plugin-hcaptcha";
export async function onRequestGet(context) { export async function onRequestGet(context) {
try { try {
@@ -15,42 +16,57 @@ export async function onRequestGet(context) {
} }
} }
export async function onRequestPost(context) { export const onRequestPost = [
try { async (context) => {
const { request, env } = 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);
},
async (context) => {
try {
const { request, env } = context;
let payload;
// Verify the JWT token try {
const authResult = await verifyJWT(context); const formData = await request.formData();
if (authResult) { payload = JSON.parse(formData.get('payload'));
return authResult; // Return the error response from the middleware } catch (e) {
} console.error("Payload parsing error:", e);
return createErrorResponse("Invalid payload", 400);
}
const { message } = await request.json(); const { message } = payload;
if (!message) { if (!message) {
return createErrorResponse("Empty message", 400); return createErrorResponse("Empty message", 400);
} }
if (message.length > 200) { if (message.length > 200) {
return createErrorResponse("Message too long", 400); return createErrorResponse("Message too long", 400);
} }
// Generate a unique ID for the message // Generate a unique ID for the message
const messageId = crypto.randomUUID(); const messageId = crypto.randomUUID();
// Store the message in D1 // Store the message in D1
await env.DB.prepare("INSERT INTO messages (id, userId, message) VALUES (?, ?, ?)") await env.DB.prepare("INSERT INTO messages (id, userId, message) VALUES (?, ?, ?)")
.bind(messageId, context.user.userId, message) .bind(messageId, context.user.userId, message)
.run(); .run();
return new Response(JSON.stringify({ id: messageId, username: context.user.username, message }), { return new Response(JSON.stringify({ id: messageId, username: context.user.username, message }), {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
} catch (error) { } catch (error) {
console.error("Message posting error:", error); console.error("Message posting error:", error);
return createErrorResponse("Message posting failed", 500); return createErrorResponse("Message posting failed", 500);
} }
} },
];
export async function onRequestDelete(context) { export async function onRequestDelete(context) {
try { try {
+31 -21
View File
@@ -1,25 +1,35 @@
import { verifyJWT } from '../middleware/auth'; import { verifyJWT } from '../middleware/auth';
import { createErrorResponse, createSuccessResponse } from '../utils'; import { createErrorResponse, createSuccessResponse } from '../utils';
import hCaptchaPlugin from "@cloudflare/pages-plugin-hcaptcha";
export async function onRequestGet(context) { export const onRequestPost = [
try { async (context) => {
// Verify the JWT token return hCaptchaPlugin({
const authResult = await verifyJWT(context); secret: context.env.hcaptcha_secret_key,
if (authResult) { sitekey: context.env.hcaptcha_site_key,
return authResult; // Return the error response from the middleware onError: (context) => {
console.error("hCaptcha error:", context.error);
return createErrorResponse("hCaptcha verification failed", 403);
}
})(context);
},
async (context) => {
try {
// Verify the JWT token
const authResult = await verifyJWT(context);
if (authResult) {
return authResult; // Return the error response from the middleware
}
const input = { prompt: '用繁體中文生成一句名言佳句' };
const response = await context.env.AI.run('@cf/meta/llama-3.2-1b-instruct', input);
const motto = response.response;
return createSuccessResponse({ motto });
} catch (error) {
console.error("Gen motto error:", error);
return createErrorResponse("Get motto failed", 500);
} }
},
// Use Cloudflare AI Gateway to proxy the request to Google AI Studio ];
const ai = context.env.AI;
const input = { prompt: '用繁體中文生成一句名言佳句' };
const response = await ai.run('@cf/meta/llama-3.2-1b-instruct', input);
const motto = response.response;
return createSuccessResponse({ motto });
} catch (error) {
console.error("Gen motto error:", error);
return createErrorResponse("Get motto failed", 500);
}
}
+60 -50
View File
@@ -1,52 +1,62 @@
import { createErrorResponse } from '../utils'; import { createErrorResponse, createSuccessResponse } from '../utils';
import hCaptchaPlugin from "@cloudflare/pages-plugin-hcaptcha";
export async function onRequestPost(context) { export const onRequestPost = [
try { async (context) => {
const { request, env } = context; return hCaptchaPlugin({
secret: context.env.hcaptcha_secret_key,
const { username, password } = await request.json(); sitekey: context.env.hcaptcha_site_key,
onError: (context) => {
if (!username || !password) { console.error("hCaptcha error:", context.error);
return createErrorResponse("Missing username or password", 400); return createErrorResponse("hCaptcha verification failed", 403);
}
if (username.length < 3) {
return createErrorResponse("Username must be at least 3 characters", 400);
}
if (password.length < 8) {
return createErrorResponse("Password must be at least 8 characters", 400);
}
if (!/^[a-zA-Z0-9]+$/.test(username)) {
return createErrorResponse("Username must be alphanumeric", 400);
}
// Check if the username already exists
const { results: existingUsers } = await env.DB.prepare("SELECT id FROM users WHERE username = ?").bind(username).all();
if (existingUsers.length > 0) {
return createErrorResponse("Username already exists", 400);
}
// Store the username and password in D1
await env.DB.prepare("INSERT INTO users (username, password, avatar) VALUES (?, ?, ?)").bind(username, password, "avatars/default.png").run();
// Get the user ID
const { results } = await env.DB.prepare("SELECT id FROM users WHERE username = ?").bind(username).all();
const userId = results[0].id;
// Registration successful, return success response
return new Response(
JSON.stringify({
success: true,
message: "Registration successful. Please login.",
}),
{
headers: { "Content-Type": "application/json" },
} }
); })(context);
} catch (error) { },
console.error("Registration error:", error); async (context) => {
return createErrorResponse("Server Error", 500); try {
} const { request, env } = context;
} let payload;
try {
const formData = await request.formData();
payload = JSON.parse(formData.get('payload'));
} catch (e) {
console.error("Payload parsing error:", e);
return createErrorResponse("Invalid payload", 400);
}
const { username, password } = payload;
if (!username || !password) {
return createErrorResponse("Missing username or password", 400);
}
if (username.length < 3) {
return createErrorResponse("Username must be at least 3 characters", 400);
}
if (password.length < 8) {
return createErrorResponse("Password must be at least 8 characters", 400);
}
if (!/^[a-zA-Z0-9]+$/.test(username)) {
return createErrorResponse("Username must be alphanumeric", 400);
}
// Check if the username already exists
const { results: existingUsers } = await env.DB.prepare("SELECT id FROM users WHERE username = ?").bind(username).all();
if (existingUsers.length > 0) {
return createErrorResponse("Username already exists", 400);
}
// Store the username and password in D1
await env.DB.prepare("INSERT INTO users (username, password, avatar) VALUES (?, ?, ?)").bind(username, password, "avatars/default.png").run();
// Registration successful, return success response
return createSuccessResponse("Registration successful", 201);
} catch (error) {
console.error("Registration error:", error);
return createErrorResponse("Server Error", 500);
}
},
];
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-tw"> <html lang="zh-tw">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com/ajax/libs/tocas/; style-src 'self' https://cdnjs.cloudflare.com/ajax/libs/tocas/; img-src 'self' blob: https://pub-e115c4e749734702abd09206cba74257.r2.dev/; font-src https://cdnjs.cloudflare.com/ajax/libs/tocas/"> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com/ajax/libs/tocas/ https://hcaptcha.com https://*.hcaptcha.com; style-src 'self' https://cdnjs.cloudflare.com/ajax/libs/tocas/ https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline'; img-src 'self' blob: https://pub-e115c4e749734702abd09206cba74257.r2.dev/; font-src https://cdnjs.cloudflare.com/ajax/libs/tocas/; frame-src https://hcaptcha.com https://*.hcaptcha.com; connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com;">
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Practicum of Attacking and Defense of Network Security</title> <title>Practicum of Attacking and Defense of Network Security</title>
+20
View File
@@ -8,6 +8,8 @@
"name": "vue", "name": "vue",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@cloudflare/pages-plugin-hcaptcha": "^1.0.4",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"file-type": "^20.4.1", "file-type": "^20.4.1",
"jose": "^6.0.10", "jose": "^6.0.10",
"pinia": "^3.0.2", "pinia": "^3.0.2",
@@ -67,6 +69,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@cloudflare/pages-plugin-hcaptcha": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@cloudflare/pages-plugin-hcaptcha/-/pages-plugin-hcaptcha-1.0.4.tgz",
"integrity": "sha512-LZ1kWAhj3/wdAAnJs/fUG8akC+FKizLu2AdhVzr4aExncXA2wjXvphktG40pvEMIoMXH5LBqP9H7YNguR14Y7Q==",
"license": "MIT"
},
"node_modules/@cloudflare/workers-types": { "node_modules/@cloudflare/workers-types": {
"version": "4.20250412.0", "version": "4.20250412.0",
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250412.0.tgz", "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250412.0.tgz",
@@ -499,6 +507,18 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@hcaptcha/vue3-hcaptcha": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@hcaptcha/vue3-hcaptcha/-/vue3-hcaptcha-1.3.0.tgz",
"integrity": "sha512-IEonS6JiYdU7uy6aeib8cYtMO4nj8utwStbA9bWHyYbOvOvhpkV+AW8vfSKh6SntYxqle/TRwhv+kU9p92CfsA==",
"license": "MIT",
"dependencies": {
"vue": "^3.2.19"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
+2
View File
@@ -13,6 +13,8 @@
"deploy": "npm run build && npm run pages:deploy" "deploy": "npm run build && npm run pages:deploy"
}, },
"dependencies": { "dependencies": {
"@cloudflare/pages-plugin-hcaptcha": "^1.0.4",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"file-type": "^20.4.1", "file-type": "^20.4.1",
"jose": "^6.0.10", "jose": "^6.0.10",
"pinia": "^3.0.2", "pinia": "^3.0.2",
+18 -3
View File
@@ -1,22 +1,32 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
const emit = defineEmits(['new-message']); const emit = defineEmits(['new-message']);
const props = defineProps(['locked']); const props = defineProps(['locked']);
const text = ref(''); const text = ref('');
const hcaptchaResponse = ref('');
const maxLength = 200; const maxLength = 200;
const handleHcaptchaVerify = (token) => {
hcaptchaResponse.value = token;
};
const handleHcaptchaExpired = () => {
hcaptchaResponse.value = '';
};
const remainingCharacters = computed(() => { const remainingCharacters = computed(() => {
return maxLength - text.value.length; return maxLength - text.value.length;
}); });
function submit() { function submit() {
if (!text.value || props.locked) { if (!text.value || props.locked || !hcaptchaResponse.value) {
return; return;
} }
emit('new-message', { text: text.value }); emit('new-message', { text: text.value, hcaptchaResponse: hcaptchaResponse.value });
text.value = ''; text.value = '';
} }
</script> </script>
@@ -39,8 +49,13 @@ function submit() {
</div> </div>
</div> </div>
</div> </div>
<VueHcaptcha
:sitekey="$hcaptchaSitekey"
@verify="handleHcaptchaVerify"
@expired="handleHcaptchaExpired"
/>
<div class="ts-wrap has-top-spaced is-end-aligned"> <div class="ts-wrap has-top-spaced is-end-aligned">
<button class="ts-button" type="submit" :class="{'is-disabled': text === '' || props.locked }">送出</button> <button class="ts-button" type="submit" :class="{'is-disabled': text === '' || props.locked || !hcaptchaResponse.value }">送出</button>
</div> </div>
</fieldset> </fieldset>
</form> </form>
+18 -5
View File
@@ -1,16 +1,24 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
const emit = defineEmits(['login-submit']); const emit = defineEmits(['login-submit']);
const username = ref(''); const username = ref('');
const password = ref(''); const password = ref('');
const router = useRouter(); const hcaptchaResponse = ref('');
const usernameError = ref(''); const usernameError = ref('');
const passwordError = ref(''); const passwordError = ref('');
const handleHcaptchaVerify = (token) => {
hcaptchaResponse.value = token;
};
const handleHcaptchaExpired = () => {
hcaptchaResponse.value = '';
};
function validateUsername() { function validateUsername() {
if (!username.value) { if (!username.value) {
usernameError.value = '使用者名稱為必填。'; usernameError.value = '使用者名稱為必填。';
@@ -49,11 +57,11 @@ const submit = () => {
validateUsername(); validateUsername();
validatePassword(); validatePassword();
if (usernameError.value || passwordError.value) { if (usernameError.value || passwordError.value || !hcaptchaResponse.value) {
return; return;
} }
emit('login-submit', { username: username.value, password: password.value }); emit('login-submit', { username: username.value, password: password.value, hcaptchaResponse: hcaptchaResponse.value });
} }
</script> </script>
@@ -81,9 +89,14 @@ const submit = () => {
</div> </div>
</div> </div>
</div> </div>
<VueHcaptcha
:sitekey="$hcaptchaSitekey"
@verify="handleHcaptchaVerify"
@expired="handleHcaptchaExpired"
/>
<div class="ts-wrap has-top-spaced is-end-aligned"> <div class="ts-wrap has-top-spaced is-end-aligned">
<button class="ts-button is-fluid" type="submit" :class="{ <button class="ts-button is-fluid" type="submit" :class="{
'is-disabled': username === '' || password === '' || usernameError !== '' || passwordError !== '' 'is-disabled': username === '' || password === '' || usernameError !== '' || passwordError !== '' || !hcaptchaResponse
}">登入</button> }">登入</button>
</div> </div>
</div> </div>
+22 -2
View File
@@ -2,6 +2,7 @@
import { ref, computed, defineProps } from 'vue'; import { ref, computed, defineProps } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { uploadAvatar } from '../../lib/api'; import { uploadAvatar } from '../../lib/api';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import { useAuthStore } from '../../stores/auth'; import { useAuthStore } from '../../stores/auth';
const props = defineProps({ const props = defineProps({
@@ -16,11 +17,20 @@ const router = useRouter();
const avatarFile = ref(null); const avatarFile = ref(null);
const avatarError = ref(''); const avatarError = ref('');
const hcaptchaResponse = ref('');
const onFileChange = (event) => { const onFileChange = (event) => {
avatarFile.value = event.target.files[0]; avatarFile.value = event.target.files[0];
}; };
const handleHcaptchaVerify = (token) => {
hcaptchaResponse.value = token;
};
const handleHcaptchaExpired = () => {
hcaptchaResponse.value = '';
};
const onSubmit = async () => { const onSubmit = async () => {
avatarError.value = ''; avatarError.value = '';
@@ -29,6 +39,11 @@ const onSubmit = async () => {
return; return;
} }
if (!hcaptchaResponse.value) {
avatarError.value = '請完成驗證。';
return;
}
if (avatarFile.value.size > 1 * 1024 * 1024) { if (avatarFile.value.size > 1 * 1024 * 1024) {
avatarError.value = '頭貼檔案需小於 1MB。'; avatarError.value = '頭貼檔案需小於 1MB。';
return; return;
@@ -40,7 +55,7 @@ const onSubmit = async () => {
} }
try { try {
await uploadAvatar(avatarFile.value, authStore.jwt); await uploadAvatar(avatarFile.value, authStore.jwt, hcaptchaResponse.value);
alert('Avatar uploaded successfully!'); alert('Avatar uploaded successfully!');
// After successful upload, reload this page // After successful upload, reload this page
router.go(0); router.go(0);
@@ -85,8 +100,13 @@ const avatarUrl = computed(() => {
</div> </div>
</div> </div>
</div> </div>
<VueHcaptcha
:sitekey="$hcaptchaSitekey"
@verify="handleHcaptchaVerify"
@expired="handleHcaptchaExpired"
/>
<div class="ts-wrap has-top-spaced is-end-aligned"> <div class="ts-wrap has-top-spaced is-end-aligned">
<button class="ts-button" :disabled="!(avatarFile && avatarError === '')" type="submit">更新</button> <button class="ts-button" :disabled="!(avatarFile && avatarError === '' && hcaptchaResponse)" type="submit">更新</button>
</div> </div>
</fieldset> </fieldset>
</form> </form>
+18 -3
View File
@@ -1,14 +1,24 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
const emit = defineEmits(['new-user']); const emit = defineEmits(['new-user']);
const username = ref(''); const username = ref('');
const password = ref(''); const password = ref('');
const hcaptchaResponse = ref('');
const usernameError = ref(''); const usernameError = ref('');
const passwordError = ref(''); const passwordError = ref('');
const handleHcaptchaVerify = (token) => {
hcaptchaResponse.value = token;
};
const handleHcaptchaExpired = () => {
hcaptchaResponse.value = '';
};
function validateUsername() { function validateUsername() {
if (!username.value) { if (!username.value) {
usernameError.value = '使用者名稱為必填。'; usernameError.value = '使用者名稱為必填。';
@@ -47,11 +57,11 @@ const submit = () => {
validateUsername(); validateUsername();
validatePassword(); validatePassword();
if (usernameError.value || passwordError.value) { if (usernameError.value || passwordError.value || !hcaptchaResponse.value) {
return; return;
} }
emit('new-user', { username: username.value, password: password.value }); emit('new-user', { username: username.value, password: password.value, hcaptchaResponse: hcaptchaResponse.value });
} }
</script> </script>
@@ -81,8 +91,13 @@ const submit = () => {
</div> </div>
</div> </div>
</div> </div>
<VueHcaptcha
:sitekey="$hcaptchaSitekey"
@verify="handleHcaptchaVerify"
@expired="handleHcaptchaExpired"
/>
<div class="ts-wrap has-top-spaced is-end-aligned"> <div class="ts-wrap has-top-spaced is-end-aligned">
<button class="ts-button" type="submit" :class="{'is-disabled': username === '' || password === '' }">送出</button> <button class="ts-button" type="submit" :class="{'is-disabled': username === '' || password === '' || !hcaptchaResponse }">送出</button>
</div> </div>
</div> </div>
</form> </form>
+45 -45
View File
@@ -2,14 +2,18 @@ import { unauthRedirectToLogin } from '../router';
const API_BASE_URL = '/api'; const API_BASE_URL = '/api';
export async function register(username, password, hCaptchaResponse) {
const formData = new FormData();
const payload = { username, password };
formData.append('payload', JSON.stringify(payload));
if (hCaptchaResponse) {
formData.append('h-captcha-response', hCaptchaResponse);
}
export async function register(username, password) {
const response = await fetch(API_BASE_URL + "/register", { const response = await fetch(API_BASE_URL + "/register", {
method: 'POST', method: 'POST',
headers: { body: formData,
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
}); });
if (!response.ok) { if (!response.ok) {
@@ -20,13 +24,18 @@ export async function register(username, password) {
return response.json(); return response.json();
} }
export async function login(username, password) { export async function login(username, password, hCaptchaResponse) {
const formData = new FormData();
const payload = { username, password };
formData.append('payload', JSON.stringify(payload));
if (hCaptchaResponse) {
formData.append('h-captcha-response', hCaptchaResponse);
}
const response = await fetch(API_BASE_URL + "/login", { const response = await fetch(API_BASE_URL + "/login", {
method: 'POST', method: 'POST',
headers: { body: formData
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
}); });
if (!response.ok) { if (!response.ok) {
@@ -37,14 +46,21 @@ export async function login(username, password) {
return response.json(); return response.json();
} }
export async function postMessage(message, jwt) { export async function postMessage(message, jwt, hCaptchaResponse) {
const formData = new FormData();
const payload = { message };
formData.append('payload', JSON.stringify(payload));
if (hCaptchaResponse) {
formData.append('h-captcha-response', hCaptchaResponse);
}
const response = await fetch(API_BASE_URL + "/messages", { const response = await fetch(API_BASE_URL + "/messages", {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Authorization': "Bearer " + jwt
'Authorization': "Bearer " + jwt,
}, },
body: JSON.stringify({ message }), body: formData
}); });
if (!response.ok) { if (!response.ok) {
@@ -81,7 +97,7 @@ export async function deleteMessage(messageId, jwt) {
return response.json(); return response.json();
} }
export async function getMessages(jwt) { export async function getMessages() {
const response = await fetch(API_BASE_URL + "/messages", { const response = await fetch(API_BASE_URL + "/messages", {
method: 'GET', method: 'GET',
headers: { headers: {
@@ -122,43 +138,20 @@ export async function getProfile(jwt) {
return response.json(); return response.json();
} }
export async function me(jwt) { export async function uploadAvatar(avatar, jwt, hCaptchaResponse) {
const response = await fetch(API_BASE_URL + "/me", { const formData = new FormData();
method: 'GET', formData.append('avatar', avatar);
headers: {
'Authorization': "Bearer " + jwt,
},
});
if (!response.ok) { if (hCaptchaResponse) {
if (response.status === 401) { formData.append('h-captcha-response', hCaptchaResponse);
unauthRedirectToLogin();
return;
}
const error = await response.json();
throw new Error(error.error || 'User operation failed');
} }
const userData = await response.json();
const avatarFilename = userData.avatar;
if (avatarFilename) {
return import.meta.env.VITE_R2_BASE_URL + "/" + avatarFilename;
}
return null;
}
export async function uploadAvatar(avatar, jwt) {
const body = new FormData();
body.append('avatar', avatar);
const response = await fetch(API_BASE_URL + "/avatars", { const response = await fetch(API_BASE_URL + "/avatars", {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Authorization': "Bearer " + jwt, 'Authorization': "Bearer " + jwt,
}, },
body: body, body: formData,
}); });
if (!response.ok) { if (!response.ok) {
@@ -174,12 +167,19 @@ export async function uploadAvatar(avatar, jwt) {
return data; return data;
} }
export async function generateMotto(jwt) { export async function generateMotto(jwt, hCaptchaResponse) {
const formData = new FormData();
if (hCaptchaResponse) {
formData.append('h-captcha-response', hCaptchaResponse);
}
try { try {
const response = await fetch('/api/motto', { const response = await fetch('/api/motto', {
method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${jwt}`, 'Authorization': `Bearer ${jwt}`,
}, },
body: formData
}); });
if (!response.ok) { if (!response.ok) {
+3
View File
@@ -9,4 +9,7 @@ const app = createApp(App);
app.use(pinia); app.use(pinia);
app.use(router); app.use(router);
app.config.globalProperties.$hcaptchaSitekey = import.meta.env.VITE_HCAPTCHA_SITEKEY;
app.mount('#app'); app.mount('#app');
+7
View File
@@ -6,3 +6,10 @@ html, body, #app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
#hcap-script {
display: flex;
align-items: center;
justify-content: center;
padding: 1em 0;
}
+1 -1
View File
@@ -23,7 +23,7 @@ const onSubmit = async (message) => {
if (!isLoggedIn) return; if (!isLoggedIn) return;
try { try {
await postMessage(message.text, authStore.jwt); await postMessage(message.text, authStore.jwt, message.hcaptchaResponse);
const response = await getMessages(); const response = await getMessages();
messages.value = response.messages; messages.value = response.messages;
+18 -2
View File
@@ -2,17 +2,27 @@
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { generateMotto as generateMottoApi } from '../lib/api'; import { generateMotto as generateMottoApi } from '../lib/api';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
const motto = ref(''); const motto = ref('');
const mottoLoading = ref(false); const mottoLoading = ref(false);
const hcaptchaResponse = ref('');
const authStore = useAuthStore(); const authStore = useAuthStore();
const handleHcaptchaVerify = (token) => {
hcaptchaResponse.value = token;
};
const handleHcaptchaExpired = () => {
hcaptchaResponse.value = '';
};
const generateMotto = async () => { const generateMotto = async () => {
mottoLoading.value = true; mottoLoading.value = true;
try { try {
const generatedMotto = await generateMottoApi(authStore.jwt); const generatedMotto = await generateMottoApi(authStore.jwt, hcaptchaResponse.value);
motto.value = generatedMotto; motto.value = generatedMotto;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -28,8 +38,14 @@ const generateMotto = async () => {
<div class="ts-box ts-content is-center-aligned"> <div class="ts-box ts-content is-center-aligned">
<div class="ts-header is-large is-center-aligned">每日金句生成器</div> <div class="ts-header is-large is-center-aligned">每日金句生成器</div>
<p class="ts-text">Powered By Cloudflare Workers AI</p> <p class="ts-text">Powered By Cloudflare Workers AI</p>
<button class="ts-button" @click="generateMotto">生成</button> <VueHcaptcha
:sitekey="$hcaptchaSitekey"
@verify="handleHcaptchaVerify"
@expired="handleHcaptchaExpired"
/>
<button class="ts-button" @click="generateMotto" :disabled="!hcaptchaResponse">生成</button>
<p class="ts-text">每日金句:</p> <p class="ts-text">每日金句:</p>
<div class="ts-loading" v-if="mottoLoading"></div>
<p class="ts-text">{{ motto }}</p> <p class="ts-text">{{ motto }}</p>
</div> </div>
</div> </div>
+2 -2
View File
@@ -7,9 +7,9 @@ import { login } from '../lib/api';
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
const onSubmit = async ({ username, password }) => { const onSubmit = async ({ username, password, hcaptchaResponse }) => {
try { try {
const response = await login(username, password); const response = await login(username, password, hcaptchaResponse);
const { jwt } = response; const { jwt } = response;
authStore.setJwt(jwt); authStore.setJwt(jwt);
alert('Login successful!'); alert('Login successful!');
+2 -2
View File
@@ -5,9 +5,9 @@ import { register } from '../lib/api';
const router = useRouter(); const router = useRouter();
const handleNewUser = async ({ username, password }) => { const handleNewUser = async ({ username, password, hcaptchaResponse }) => {
try { try {
const response = await register(username, password); const response = await register(username, password, hcaptchaResponse);
alert(response.message || 'Registration successful! Please log in.'); alert(response.message || 'Registration successful! Please log in.');
// Redirect to login page // Redirect to login page