feat: midterm shit done

This commit is contained in:
Tony Yang
2025-04-15 03:59:33 +08:00
parent f093df29a1
commit f7ee02586b
34 changed files with 1460 additions and 197 deletions
+1
View File
@@ -0,0 +1 @@
VITE_R2_BASE_URL=https://pub-e115c4e749734702abd09206cba74257.r2.dev/
+1
View File
@@ -0,0 +1 @@
VITE_R2_BASE_URL=/api/
+30
View File
@@ -0,0 +1,30 @@
import { createErrorResponse } from '../../utils';
export async function onRequestGet(context) {
try {
const { env, params } = context;
const { filename } = params;
if (!filename) {
return createErrorResponse("Filename is required", 400);
}
const key = "avatars/" + filename;
const object = await env.MY_BUCKET.get(key);
if (object === null) {
return new Response("Object Not Found", { status: 404 });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);
return new Response(object.body, {
headers,
});
} catch (error) {
console.error("Avatar retrieval error:", error);
return createErrorResponse("Server Error", 500);
}
}
+50
View File
@@ -0,0 +1,50 @@
import { verifyJWT } from '../../middleware/auth';
import { createErrorResponse, createSuccessResponse } from '../../utils';
import { fileTypeFromBuffer } from 'file-type';
export async function onRequestPut(context) {
try {
const { request, env } = context;
// Verify the JWT token
const authResult = await verifyJWT(context);
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 > 2 * 1024 * 1024) {
return createErrorResponse("Avatar must be less than 2MB", 400);
}
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 fileExtension = fileTypeResult.ext;
const objectName = `avatars/${context.user.userId}.${fileExtension}`;
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);
}
}
+3
View File
@@ -0,0 +1,3 @@
export function onRequest(context) {
return new Response("Hello, world!")
}
+58
View File
@@ -0,0 +1,58 @@
import { SignJWT } from 'jose';
import { createSuccessResponse, createErrorResponse } from "../utils";
export async function onRequestPost(context) {
try {
const { request, env } = context;
const { username, password } = await request.json();
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);
}
// Get the stored password from D1
const { results } = await env.DB.prepare("SELECT password FROM users WHERE username = ?").bind(username).all();
if (!results || results.length === 0) {
return new Response(JSON.stringify({"error": "Invalid username or password"}), { status: 403, headers: { 'Content-Type': 'application/json' } });
}
const storedPassword = results[0].password;
// Compare the password to the stored 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
const { results: userResults } = await env.DB.prepare("SELECT * FROM users WHERE username = ?").bind(username).all();
const jwtPayload = (({ id, username }) => ({ id, username }))(userResults[0]);
// 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) {
console.error("Login error:", error);
return createErrorResponse("Login failed", 500);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { verifyJWT } from "../middleware/auth";
import { createErrorResponse, createSuccessResponse } from "../utils";
export async function onRequest(context) {
try {
// Verify JWT token
const authResult = await verifyJWT(context);
if (authResult) {
return authResult; // Return error response if authentication fails
}
// Get user information from context
const { user } = context;
// Fetch user profile from D1 database
const { results } = await context.env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(user.userId).all();
if (!results || results.length === 0) {
// use 401 instead to redirect to login page
return createErrorResponse("User not found", 401);
}
const userProfile = results[0];
const { password, ...profile } = userProfile; // Exclude password from the profile
// Return the profile as a JSON response
return createSuccessResponse(profile, 200);
} catch (error) {
console.error("Error:", error);
return createErrorResponse("Internal Server Error", 500);
}
}
+81
View File
@@ -0,0 +1,81 @@
import { verifyJWT } from '../middleware/auth';
import { createErrorResponse, createSuccessResponse } from '../utils';
export async function onRequestGet(context) {
try {
const { env } = context;
// Get the messages from D1
const { results } = await env.DB.prepare("SELECT messages.id, userId, username, message, timestamp, users.avatar FROM messages LEFT JOIN users ON users.id = messages.userId").all();
return createSuccessResponse({ messages: results });
} catch (error) {
console.error("Get messages error:", error);
return createErrorResponse("Get messages failed", 500);
}
}
export async function onRequestPost(context) {
try {
const { request, env } = context;
// Verify the JWT token
const authResult = await verifyJWT(context);
if (authResult) {
return authResult; // Return the error response from the middleware
}
const { message } = await request.json();
if (!message) {
return new Response(JSON.stringify({"error": "Missing message"}), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Generate a unique ID for the message
const messageId = crypto.randomUUID();
// Store the message in D1
await env.DB.prepare("INSERT INTO messages (id, userId, message) VALUES (?, ?, ?)")
.bind(messageId, context.user.userId, message)
.run();
return new Response(JSON.stringify({ id: messageId, username: context.user.username, message }), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error("Message posting error:", error);
return createErrorResponse("Message posting failed", 500);
}
}
export async function onRequestDelete(context) {
try {
const { request, env } = context;
// Verify the JWT token
const authResult = await verifyJWT(context);
if (authResult) {
return authResult; // Return the error response from the middleware
}
const { messageId } = await request.json();
if (!messageId) {
return new Response(JSON.stringify({"error": "Missing messageId"}), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Delete the message from D1
await env.DB.prepare("DELETE FROM messages WHERE id = ?").bind(messageId).run();
return createSuccessResponse({ message: "Message deleted successfully" });
} catch (error) {
console.error("Message deletion error:", error);
return createErrorResponse("Message deletion failed", 500);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { createErrorResponse } from '../utils';
export async function onRequestPost(context) {
try {
const { request, env } = context;
const { username, password } = await request.json();
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();
// 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" },
}
);
} catch (error) {
console.error("Registration error:", error);
return createErrorResponse("Server Error", 500);
}
}
-47
View File
@@ -1,47 +0,0 @@
/**
* @typedef {import('@cloudflare/workers-types').D1Database} D1Database
*/
export async function onRequestGet(context) {
const DB = context.env.DB;
// return all users
const stmt = await DB.prepare("SELECT * FROM users");
const users = (await stmt.run()).results;
return new Response(JSON.stringify(users), {
headers: { "Content-Type": "application/json" },
});
}
export async function onRequestPost(context) {
/**
* @type {D1Database}
*/
const DB = context.env.DB;
const { name } = await context.request.json();
// create a new user
const stmt = DB.prepare("INSERT INTO users (name) VALUES (?)").bind(name);
try {
const result = await stmt.run();
if (!result.success) {
throw new Error("Failed to create user");
}
const userId = result.meta.last_row_id;
const stmt2 = await DB.prepare("SELECT * FROM users WHERE id = ?").bind(userId);
const userResult = await stmt2.run();
const user = userResult.results[0];
return new Response(JSON.stringify(user), {
headers: { "Content-Type": "application/json" },
status: 201,
});
} catch (error) {
console.error("Error creating user:", error);
return new Response("Error creating user", { status: 500 });
}
}
+26
View File
@@ -0,0 +1,26 @@
import * as jose from 'jose';
import { createErrorResponse } from "../utils";
export async function verifyJWT(context) {
const { request, env } = context;
// Check for a valid JWT token
const authHeader = request.headers.get("Authorization");
if (!authHeader) {
return createErrorResponse("Missing Authorization header", 401);
}
const token = authHeader.split(" ")[1];
try {
// Verify the token
const { payload, protectedHeader } = await jose.jwtVerify(token, new TextEncoder().encode(env.JWT_SECRET), {
issuer: 'urn:example:issuer',
audience: 'urn:example:audience',
});
context.user = { userId: payload.id, username: payload.username };
return; // Continue to the next middleware or function
} catch (error) {
return createErrorResponse("Invalid or expired token", 401);
}
}
+13
View File
@@ -0,0 +1,13 @@
export function createErrorResponse(message, status) {
return new Response(JSON.stringify({"error": message}), {
status: status,
headers: { 'Content-Type': 'application/json' },
});
}
export function createSuccessResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status: status,
headers: { 'Content-Type': 'application/json' },
});
}
+303 -6
View File
@@ -8,6 +8,9 @@
"name": "vue", "name": "vue",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"file-type": "^20.4.1",
"jose": "^6.0.10",
"pinia": "^3.0.2",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
@@ -65,9 +68,9 @@
} }
}, },
"node_modules/@cloudflare/workers-types": { "node_modules/@cloudflare/workers-types": {
"version": "4.20250327.0", "version": "4.20250412.0",
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250327.0.tgz", "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250412.0.tgz",
"integrity": "sha512-rkoGnSY/GgBLCuhjZMIC3mt0jjqqvL17uOK92OI4eivmE+pMFOAchowDxIWOzDyYe5vwNCakbCeIM/FrSmwGJA==", "integrity": "sha512-ukQE+TRc5HNkM6VvGfTNC9x54TLQKjdcm624F8Qh1ZRe0iJrW2/j1eYgvJABJPexDousYCR7VzCGteShLcBJYQ==",
"dev": true, "dev": true,
"license": "MIT OR Apache-2.0" "license": "MIT OR Apache-2.0"
}, },
@@ -806,6 +809,30 @@
"win32" "win32"
] ]
}, },
"node_modules/@tokenizer/inflate": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
"integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"fflate": "^0.8.2",
"token-types": "^6.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -883,6 +910,30 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/devtools-kit": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.2.tgz",
"integrity": "sha512-CY0I1JH3Z8PECbn6k3TqM1Bk9ASWxeMtTCvZr7vb+CHi+X/QwQm5F1/fPagraamKMAHVfuuCbdcnNg1A4CYVWQ==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.2",
"birpc": "^0.2.19",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.1"
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.2.tgz",
"integrity": "sha512-uBFxnp8gwW2vD6FrJB8JZLUzVb6PNRG0B0jBnHsOH8uKyva2qINY8PTF5Te4QlTbMDqU5K6qtJDr6cNsKWhbOA==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.13", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz",
@@ -960,6 +1011,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/birpc": {
"version": "0.2.19",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.19.tgz",
"integrity": "sha512-5WeXXAvTmitV1RqJFppT5QtUiz2p1mRSYU000Jkft5ZUCLJIk4uQriYNO50HknxKwM6jd8utNc66K1qGIwwWBQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/braces": { "node_modules/braces": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -998,12 +1058,44 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/copy-anything": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
"integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
"license": "MIT",
"dependencies": {
"is-what": "^4.1.8"
},
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -1090,6 +1182,30 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-type": {
"version": "20.4.1",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz",
"integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.2.6",
"strtok3": "^10.2.0",
"token-types": "^6.0.0",
"uint8array-extras": "^1.4.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1153,6 +1269,32 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -1199,6 +1341,27 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/is-what": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
"integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
"license": "MIT",
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/jose": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.10.tgz",
"integrity": "sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jsonfile": { "node_modules/jsonfile": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
@@ -1245,6 +1408,18 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1286,6 +1461,25 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/peek-readable": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-7.0.0.tgz",
"integrity": "sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1305,6 +1499,36 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pinia": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.2.tgz",
"integrity": "sha512-sH2JK3wNY809JOeiiURUR0wehJ9/gd9qFN2Y828jCbxEzKEmEt0pzCXwqiSTfuRsK9vQsOflSdnbdBOGrhtn+g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.2"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.2.tgz",
"integrity": "sha512-1syn558KhyN+chO5SjlZIwJ8bV/bQ1nOVTG66t2RbG66ZGekyiYNmRO7X9BJCXQqPsFHlnksqvPhce2qpzxFnA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.2"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.3", "version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
@@ -1378,6 +1602,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.36.0", "version": "4.36.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.36.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.36.0.tgz",
@@ -1450,6 +1680,44 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strtok3": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.2.2.tgz",
"integrity": "sha512-Xt18+h4s7Z8xyZ0tmBoRmzxcop97R4BAh+dXouUDCYn+Em+1P3qpkUfI5ueWLT8ynC5hZ+q4iPEmGG1urvQGBg==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0",
"peek-readable": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/superjson": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz",
"integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==",
"license": "MIT",
"dependencies": {
"copy-anything": "^3.0.2"
},
"engines": {
"node": ">=16"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1463,6 +1731,35 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/token-types": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.0.0.tgz",
"integrity": "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/uint8array-extras": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz",
"integrity": "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/universalify": { "node_modules/universalify": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -1474,9 +1771,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.2.3", "version": "6.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
"integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==", "integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+5 -1
View File
@@ -6,12 +6,16 @@
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"build": "vite build", "build": "vite build",
"watch": "vite build --watch --mode development",
"preview": "vite preview", "preview": "vite preview",
"pages:dev": "wrangler pages dev --proxy 5173 -- npm run dev", "pages:dev": "wrangler pages dev --proxy 5173",
"pages:deploy": "wrangler pages deploy dist", "pages:deploy": "wrangler pages deploy dist",
"deploy": "npm run build && npm run pages:deploy" "deploy": "npm run build && npm run pages:deploy"
}, },
"dependencies": { "dependencies": {
"file-type": "^20.4.1",
"jose": "^6.0.10",
"pinia": "^3.0.2",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
+12 -1
View File
@@ -1,6 +1,17 @@
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS users;
CREATE TABLE users ( CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(50) NOT NULL UNIQUE username VARCHAR(50) NOT NULL,
password VARCHAR(255) NOT NULL,
avatar VARCHAR(50)
);
CREATE TABLE messages (
id VARCHAR(36) PRIMARY KEY NOT NULL,
userId INTEGER NOT NULL,
message TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (userId) REFERENCES users(id)
); );
+17 -17
View File
@@ -1,19 +1,23 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref, computed } from 'vue';
const emit = defineEmits(['new-message']); const emit = defineEmits(['new-message']);
const props = defineProps(['locked']);
const name = ref(''); const text = ref('');
const message = ref(''); const maxLength = 1024;
const remainingCharacters = computed(() => {
return maxLength - text.value.length;
});
function submit() { function submit() {
if (!name || !message) { if (!text.value || props.locked) {
return; return;
} }
emit('new-message', { name: name.value, message: message.value }); emit('new-message', { text: text.value });
name.value = ''; text.value = '';
message.value = '';
} }
</script> </script>
@@ -22,25 +26,21 @@ function submit() {
<fieldset class="ts-fieldset"> <fieldset class="ts-fieldset">
<legend class="ts-legend">New Message</legend> <legend class="ts-legend">New Message</legend>
<div class="ts-wrap is-vertical"> <div class="ts-wrap is-vertical">
<div class="ts-control">
<div class="label">Name</div>
<div class="content is-fluid">
<div class="ts-input">
<input name="name" type="text" placeholder="Name" v-model="name" />
</div>
</div>
</div>
<div class="ts-control"> <div class="ts-control">
<div class="label">Message</div> <div class="label">Message</div>
<div class="content is-fluid"> <div class="content is-fluid">
<div class="ts-input"> <div class="ts-input">
<textarea name="message" placeholder="Message" v-model="message"></textarea> <textarea v-if="!locked" name="message" placeholder="Message" v-model="text" :maxlength="maxLength" rows="5"></textarea>
<textarea v-else placeholder="Please log in to leave message" :maxlength="maxLength" rows="5" disabled></textarea>
</div>
<div class="ts-text is-small is-secondary is-end-aligned">
{{ remainingCharacters }} / {{ maxLength }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<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': name === '' || message === '' }">Submit</button> <button class="ts-button" type="submit" :class="{'is-disabled': text === '' || props.locked }">Submit</button>
</div> </div>
</fieldset> </fieldset>
</form> </form>
+36 -4
View File
@@ -1,16 +1,44 @@
<script setup> <script setup>
defineProps(['message']); import { defineProps, defineEmits, computed } from 'vue';
import { useAuthStore } from '../../stores/auth';
const authStore = useAuthStore();
const props = defineProps(['message']);
const emit = defineEmits(['delete-message']);
const userId = authStore.id;
const onDelete = () => {
emit('delete-message', props.message.id);
};
const avatarUrl = computed(() => {
return props.message?.avatar ? import.meta.env.VITE_R2_BASE_URL + props.message?.avatar : '';
});
</script> </script>
<template> <template>
<div class="ts-conversation"> <div class="ts-conversation">
<div class="avatar"> <div class="avatar">
<img src="../../assets/user.png" alt="Avatar" /> <img :src="avatarUrl" alt="Avatar" />
</div> </div>
<div class="content"> <div class="content">
<div class="bubble"> <div class="bubble">
<div class="author">{{ message.name }}</div> <div class="ts-grid">
<div class="text">{{ message.message }}</div> <div class="column is-fluid">
<div class="author">{{ message.username }}</div>
<div class="text">{{ message.message }}</div>
<div class="meta">
<div class="item">{{ new Date(message.timestamp).toLocaleString('zh-TW', {timeZone: "-08:00"}) }}</div>
</div>
</div>
<div class="column" v-if="userId === message.userId">
<button class="ts-button is-icon is-negative is-outlined" @click="onDelete">
<span class="ts-icon is-trash-icon"></span>
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -20,4 +48,8 @@ defineProps(['message']);
.ts-conversation > .content { .ts-conversation > .content {
width: 100%; width: 100%;
} }
.ts-conversation .bubble .text {
white-space: pre-line;
}
</style> </style>
-36
View File
@@ -1,36 +0,0 @@
<script setup>
import { ref } from 'vue';
const emit = defineEmits(['new-user']);
const name = defineModel();
function submit() {
if (!name) {
return;
}
emit('new-user', { name: name.value });
}
</script>
<template>
<form @submit.prevent="submit">
<fieldset class="ts-fieldset">
<legend class="ts-legend">New User</legend>
<div class="ts-wrap is-vertical">
<div class="ts-control">
<div class="label">Name</div>
<div class="content is-fluid">
<div class="ts-input">
<input name="name" type="text" placeholder="Name" v-model="name" />
</div>
</div>
</div>
</div>
<div class="ts-wrap has-top-spaced is-end-aligned">
<button class="ts-button" type="submit" :class="{'is-disabled': name === '' }">Submit</button>
</div>
</fieldset>
</form>
</template>
+109
View File
@@ -0,0 +1,109 @@
<script setup>
import { ref, watch } from 'vue';
import { login } from '../../lib/api';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth';
const username = ref('');
const password = ref('');
const router = useRouter();
const usernameError = ref('');
const passwordError = ref('');
const authStore = useAuthStore();
function validateUsername() {
if (!username.value) {
usernameError.value = 'Username is required.';
} else if (username.value.length < 3) {
usernameError.value = 'Username must be at least 3 characters.';
} else {
usernameError.value = '';
}
}
function validatePassword() {
if (!password.value) {
passwordError.value = 'Password is required.';
} else if (password.value.length < 8) {
passwordError.value = 'Password must be at least 8 characters.';
} else {
passwordError.value = '';
}
}
watch(
() => username.value,
() => {
validateUsername();
}
);
watch(
() => password.value,
() => {
validatePassword();
}
);
const onSubmit = async () => {
validateUsername();
validatePassword();
if (usernameError.value || passwordError.value) {
return;
}
try {
const response = await login(username.value, password.value);
const { jwt } = response;
authStore.setJwt(jwt);
alert('Login successful!');
router.push('/profile');
} catch (error) {
alert("Login failed: " + error.message);
}
};
</script>
<template>
<form @submit.prevent="onSubmit">
<fieldset class="ts-fieldset">
<legend class="ts-legend">Login</legend>
<div class="ts-wrap is-vertical">
<div class="ts-control">
<div class="label">Username</div>
<div class="content is-fluid">
<div class="ts-input" :class="{'is-negative': usernameError}">
<input name="username" type="text" placeholder="Username" v-model="username" />
</div>
<div class="ts-text is-small is-negative" v-if="usernameError">{{ usernameError }}</div>
</div>
</div>
<div class="ts-control">
<div class="label">Password</div>
<div class="content is-fluid">
<div class="ts-input" :class="{'is-negative': passwordError}">
<input name="password" type="password" placeholder="Password" v-model="password" />
</div>
<div class="ts-text is-small is-negative" v-if="passwordError">{{ passwordError }}</div>
<div class="ts-text is-small is-negative">Warning: This is for demonstration purposes only. Passwords are stored in plaintext!</div>
</div>
</div>
</div>
<div class="ts-wrap has-top-spaced is-end-aligned">
<button class="ts-button" type="submit" :class="{
'is-disabled': username === '' || password === '' || usernameError !== '' || passwordError !== ''
}">Submit</button>
</div>
</fieldset>
</form>
</template>
<style scoped>
.ts-error {
color: red;
font-size: 0.8em;
}
</style>
+37 -4
View File
@@ -1,5 +1,25 @@
<script setup> <script setup>
import { RouterLink } from 'vue-router'; import { useRouter, RouterLink } from 'vue-router';
import { computed } from 'vue';
import { useAuthStore } from '../stores/auth';
const router = useRouter();
const authStore = useAuthStore();
const isLoggedIn = computed(() => {
return authStore.isLoggedIn;
});
const username = computed(() => {
return authStore.username;
});
function logout() {
authStore.clearJwt();
alert('登出成功!');
// Redirect to the login page
router.push('/login');
}
</script> </script>
<template> <template>
@@ -8,11 +28,19 @@ import { RouterLink } from 'vue-router';
<div class="ts-wrap"> <div class="ts-wrap">
<RouterLink class="ts-header is-brand" to="/">網路攻防實習</RouterLink> <RouterLink class="ts-header is-brand" to="/">網路攻防實習</RouterLink>
<div class="ts-tab is-tall"> <div class="ts-tab is-tall">
<RouterLink class="item" :to="route.path" :class="{'is-active': $route.path == route.path}" v-for="route in $router.options.routes.filter(route => route.meta.showInNav)">{{ route.meta.navName }}</RouterLink> <RouterLink class="item" :to="route.path" :class="{'is-active': route.path == $route.path}" v-for="route in $router.options.routes.filter(route => route.meta.showInNav)">{{ route.meta.navName }}</RouterLink>
</div> </div>
</div> </div>
<div class="actions"> <div class="actions ts-wrap">
<button class="ts-button is-disabled" disabled>登入</button> <template v-if="isLoggedIn">
<span class="username">Hi, {{ username }}</span>
<RouterLink class="ts-button" to="/profile">個人資料</RouterLink>
<div class="ts-button" @click="logout">登出</div>
</template>
<template v-else>
<RouterLink class="ts-button" to="/login">登入</RouterLink>
<RouterLink class="ts-button" to="/register">註冊</RouterLink>
</template>
</div> </div>
</div> </div>
</nav> </nav>
@@ -33,4 +61,9 @@ nav {
.ts-header.is-brand { .ts-header.is-brand {
text-decoration: none; text-decoration: none;
} }
.username {
display: flex;
align-items: center;
}
</style> </style>
+102
View File
@@ -0,0 +1,102 @@
<script setup>
import { ref, computed, defineProps } from 'vue';
import { useRouter } from 'vue-router';
import { uploadAvatar } from '../../lib/api';
import { useAuthStore } from '../../stores/auth';
const props = defineProps({
profile: {
type: Object,
required: true
}
});
const authStore = useAuthStore();
const router = useRouter();
const jwt = authStore.jwt;
const avatarFile = ref(null);
const avatarError = ref('');
const onFileChange = (event) => {
avatarFile.value = event.target.files[0];
};
const onSubmit = async () => {
avatarError.value = '';
if (!avatarFile.value) {
avatarError.value = 'Avatar is required.';
return;
}
if (avatarFile.value.size > 2 * 1024 * 1024) {
avatarError.value = 'Avatar must be less than 2MB.';
return;
}
if (avatarFile.value.type !== 'image/jpeg' && avatarFile.value.type !== 'image/png') {
avatarError.value = 'Avatar must be a JPG or PNG image.';
return;
}
try {
await uploadAvatar(avatarFile.value, jwt);
alert('Avatar uploaded successfully!');
// After successful upload, reload this page
router.go(0);
} catch (error) {
alert("Avatar upload failed: " + error.message);
}
};
const avatarUrl = computed(() => {
if (avatarFile.value) {
return URL.createObjectURL(avatarFile.value);
}
return props.profile?.avatar ? import.meta.env.VITE_R2_BASE_URL + props.profile.avatar : '';
});
</script>
<template>
<form @submit.prevent="onSubmit">
<fieldset class="ts-fieldset">
<legend class="ts-legend">Update Profile</legend>
<div class="ts-wrap is-vertical">
<div class="ts-control">
<div class="label">Username</div>
<div class="content is-fluid">
<div class="ts-input">
<input type="text" name="username" placeholder="Username" :value="props.profile?.username" readonly disabled />
</div>
</div>
</div>
<div class="ts-control">
<div class="label">Avatar</div>
<div class="content is-fluid">
<div v-if="avatarUrl">
<img :src="avatarUrl" alt="Avatar" style="max-width: 100px; max-height: 100px;" />
</div>
<div class="ts-file" :class="{'is-negative': avatarError}">
<input type="file" accept="image/jpeg, image/png" @change="onFileChange" />
</div>
<div class="ts-text is-small is-negative" v-if="avatarError">{{ avatarError }}</div>
<div class="ts-text is-small">Avatar must be a JPG or PNG image less than 2MB.</div>
</div>
</div>
</div>
<div class="ts-wrap has-top-spaced is-end-aligned">
<button class="ts-button" type="submit">Update</button>
</div>
</fieldset>
</form>
</template>
<style scoped>
.ts-error {
color: red;
font-size: 0.8em;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<script setup>
import { ref, watch } from 'vue';
const emit = defineEmits(['new-user']);
const username = ref('');
const password = ref('');
const usernameError = ref('');
const passwordError = ref('');
function validateUsername() {
if (!username.value) {
usernameError.value = 'Username is required.';
} else if (username.value.length < 3) {
usernameError.value = 'Username must be at least 3 characters.';
} else {
usernameError.value = '';
}
}
function validatePassword() {
if (!password.value) {
passwordError.value = 'Password is required.';
} else if (password.value.length < 8) {
passwordError.value = 'Password must be at least 8 characters.';
} else {
passwordError.value = '';
}
}
watch(
() => username.value,
() => {
validateUsername();
}
);
watch(
() => password.value,
() => {
validatePassword();
}
);
const submit = () => {
validateUsername();
validatePassword();
if (usernameError.value || passwordError.value) {
return;
}
emit('new-user', { username: username.value, password: password.value });
}
</script>
<template>
<form @submit.prevent="submit">
<fieldset class="ts-fieldset">
<legend class="ts-legend">New User</legend>
<div class="ts-wrap is-vertical">
<div class="ts-control">
<div class="label">Username</div>
<div class="content is-fluid">
<div class="ts-input" :class="{'is-negative': usernameError}">
<input name="username" type="text" placeholder="Username" v-model="username" @input="validateUsername" />
</div>
<div class="ts-text is-small is-negative" v-if="usernameError">{{ usernameError }}</div>
<div class="ts-text is-small">Username must be alphanumeric and at least 3 characters.</div>
</div>
</div>
<div class="ts-control">
<div class="label">Password</div>
<div class="content is-fluid">
<div class="ts-input" :class="{'is-negative': passwordError}">
<input name="password" type="password" placeholder="Password" v-model="password" @input="validatePassword" />
</div>
<div class="ts-text is-small is-negative" v-if="passwordError">{{ passwordError }}</div>
<div class="ts-text is-small">Password must be at least 8 characters.</div>
<div class="ts-text is-small is-negative">Warning: This is for demonstration purposes only. Passwords are stored in plaintext!</div>
</div>
</div>
</div>
<div class="ts-wrap has-top-spaced is-end-aligned">
<button class="ts-button" type="submit" :class="{'is-disabled': username === '' || password === '' }">Submit</button>
</div>
</fieldset>
</form>
</template>
<style scoped>
.ts-error {
color: red;
font-size: 0.8em;
}
</style>
+175
View File
@@ -0,0 +1,175 @@
import { unauthRedirectToLogin } from '../router';
const API_BASE_URL = '/api';
export async function register(username, password) {
const response = await fetch(API_BASE_URL + "/register", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Registration failed');
}
return response.json();
}
export async function login(username, password) {
const response = await fetch(API_BASE_URL + "/login", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Login failed');
}
return response.json();
}
export async function postMessage(message, jwt) {
const response = await fetch(API_BASE_URL + "/messages", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': "Bearer " + jwt,
},
body: JSON.stringify({ message }),
});
if (!response.ok) {
if (response.status === 401) {
unauthRedirectToLogin();
return;
}
const error = await response.json();
throw new Error(error.error || 'Posting message failed');
}
return response.json();
}
export async function deleteMessage(messageId, jwt) {
const response = await fetch(API_BASE_URL + "/messages", {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': "Bearer " + jwt,
},
body: JSON.stringify({ messageId }),
});
if (!response.ok) {
if (response.status === 401) {
unauthRedirectToLogin();
return;
}
const error = await response.json();
throw new Error(error.error || 'Deleting message failed');
}
return response.json();
}
export async function getMessages(jwt) {
const response = await fetch(API_BASE_URL + "/messages", {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
if (response.status === 401) {
unauthRedirectToLogin();
return;
}
const error = await response.json();
throw new Error(error.error || 'Getting messages failed');
}
return response.json();
}
export async function getProfile(jwt) {
const response = await fetch(API_BASE_URL + "/me", {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': "Bearer " + jwt,
},
});
if (!response.ok) {
if (response.status === 401) {
unauthRedirectToLogin();
return;
}
const error = await response.json();
throw new Error(error.error || 'Getting profile failed');
}
return response.json();
}
export async function me(jwt) {
const response = await fetch(API_BASE_URL + "/me", {
method: 'GET',
headers: {
'Authorization': "Bearer " + jwt,
},
});
if (!response.ok) {
if (response.status === 401) {
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", {
method: 'PUT',
headers: {
'Authorization': "Bearer " + jwt,
},
body: body,
});
if (!response.ok) {
if (response.status === 401) {
unauthRedirectToLogin();
return;
}
const error = await response.json();
throw new Error(error.error || 'Avatar upload failed');
}
const data = await response.json();
return data;
}
+3
View File
@@ -2,8 +2,11 @@ import { createApp } from 'vue';
import './style.css'; import './style.css';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
import { createPinia } from 'pinia';
const pinia = createPinia();
const app = createApp(App); const app = createApp(App);
app.use(pinia);
app.use(router); app.use(router);
app.mount('#app'); app.mount('#app');
+30 -13
View File
@@ -1,9 +1,11 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import HomeView from '../views/HomeView.vue'; import HomeView from '../views/HomeView.vue';
import BoardView from '../views/BoardView.vue'; import BoardView from '../views/BoardView.vue';
import AboutView from '../views/AboutView.vue'; import AboutView from '../views/AboutView.vue';
import UsersView from '../views/UsersView.vue'; import LoginView from '../views/LoginView.vue';
import CreateUserView from '../views/CreateUserView.vue'; import ProfileView from '../views/ProfileView.vue';
import RegisterView from '../views/RegisterView.vue';
const routes = [ const routes = [
{ {
@@ -35,22 +37,30 @@ const routes = [
} }
}, },
{ {
path: '/users', path: '/login',
name: 'Users', name: 'Login',
component: UsersView, component: LoginView,
meta: { meta: {
keepAlive: true, showInNav: false,
showInNav: true, navName: '登入'
navName: '使用者列表'
} }
}, },
{ {
path: '/users/create', path: '/register',
name: 'CreateUser', name: 'Register',
component: CreateUserView, component: RegisterView,
meta: { meta: {
showInNav: true, showInNav: false,
navName: '新增使用者' navName: '註冊'
}
},
{
path: '/profile',
name: 'Profile',
component: ProfileView,
meta: {
showInNav: false,
navName: '個人資料'
} }
} }
]; ];
@@ -60,4 +70,11 @@ const router = createRouter({
routes routes
}); });
export function unauthRedirectToLogin() {
const auth = useAuthStore();
auth.clearJwt();
alert('Your session has expired. Redirecting to login.');
router.push('/login');
}
export default router; export default router;
+56
View File
@@ -0,0 +1,56 @@
import { defineStore } from 'pinia';
export const useAuthStore = defineStore('auth', {
state: () => ({
jwt: localStorage.getItem('jwt') || null,
}),
getters: {
isLoggedIn: (state) => !!state.jwt,
id: (state) => {
if (state.jwt) {
try {
const payload = JSON.parse(atob(state.jwt.split('.')[1]));
return payload.id;
} catch (error) {
console.error("Failed to decode JWT:", error);
return null;
}
}
return null;
},
username: (state) => {
if (state.jwt) {
try {
const payload = JSON.parse(atob(state.jwt.split('.')[1]));
return payload.username;
} catch (error) {
console.error("Failed to decode JWT:", error);
return null;
}
}
return null;
},
avatar: (state) => {
if (state.jwt) {
try {
const payload = JSON.parse(atob(state.jwt.split('.')[1]));
return payload.avatar;
} catch (error) {
console.error("Failed to decode JWT:", error);
return null;
}
}
return null;
}
},
actions: {
setJwt(newJwt) {
this.jwt = newJwt;
localStorage.setItem('jwt', newJwt);
},
clearJwt() {
this.jwt = null;
localStorage.removeItem('jwt');
},
},
});
+40 -5
View File
@@ -1,21 +1,56 @@
<script setup> <script setup>
import BoardForm from '../components/Board/BoardForm.vue'; import BoardForm from '../components/Board/BoardForm.vue';
import BoardMessage from '../components/Board/BoardMessage.vue'; import BoardMessage from '../components/Board/BoardMessage.vue';
import { ref } from 'vue'; import { ref, onMounted } from 'vue';
import { postMessage, deleteMessage, getMessages } from '../lib/api';
import { useAuthStore } from '../stores/auth';
const messages = ref([]); const messages = ref([]);
const onSubmit = (message) => { const authStore = useAuthStore();
messages.value.push(message); const jwt = authStore.jwt;
const isLoggedIn = authStore.isLoggedIn;
onMounted(async () => {
try {
const response = await getMessages(jwt);
messages.value = response.messages;
} catch (error) {
alert("Failed to get messages: " + error.message);
}
});
const onSubmit = async (message) => {
if (!isLoggedIn) return;
try {
await postMessage(message.text, jwt);
const response = await getMessages();
messages.value = response.messages;
} catch (error) {
alert("Failed to post message: " + error.message);
}
};
const onDelete = async (messageId) => {
try {
await deleteMessage(messageId, jwt);
const response = await getMessages();
messages.value = response.messages;
} catch (error) {
alert("Failed to delete message: " + error.message);
}
}; };
</script> </script>
<template> <template>
<div class="ts-container"> <div class="ts-container">
<BoardForm @new-message="onSubmit" /> <BoardForm :locked="!isLoggedIn" @new-message="onSubmit" />
<div class="ts-content is-horizontally-fitted is-vertically-padded"> <div class="ts-content is-horizontally-fitted is-vertically-padded">
<div class="ts-wrap is-vertical"> <div class="ts-wrap is-vertical">
<BoardMessage :message="message" v-for="message in messages" :key="message.id" /> <BoardMessage :message="message" v-for="message in messages" :key="message.id" @delete-message="onDelete(message.id)" />
</div> </div>
</div> </div>
</div> </div>
-32
View File
@@ -1,32 +0,0 @@
<script setup>
import CreateForm from '../components/CreateUser/CreateForm.vue';
import { ref } from 'vue';
const name = ref('');
const onSubmit = async () => {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name.value,
}),
});
if (!response.ok) {
alert('Failed to create user');
return;
}
alert('User created');
name.value = '';
};
</script>
<template>
<div class="ts-container">
<CreateForm v-model="name" @new-user="onSubmit" />
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
<template>
<div class="ts-container">
<LoginForm />
</div>
</template>
<script setup>
import LoginForm from '../components/Login/LoginForm.vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const onSubmit = async () => {
try {
const response = await login(username.value, password.value);
const { jwt } = response;
localStorage.setItem('jwt', jwt);
alert('Login successful!');
router.push('/');
} catch (error) {
alert("Login failed: " + error.message);
}
};
</script>
+25
View File
@@ -0,0 +1,25 @@
<template>
<div class="ts-container">
<ProfileForm :profile="profile" />
</div>
</template>
<script setup>
import ProfileForm from '../components/Profile/ProfileForm.vue';
import { onMounted, ref } from 'vue';
import { useAuthStore } from '../stores/auth';
import { getProfile } from '../lib/api';
const authStore = useAuthStore();
const profile = ref(null);
onMounted(async () => {
try {
const data = await getProfile(authStore.jwt);
profile.value = data;
} catch (error) {
console.error("Failed to fetch profile:", error);
alert("Failed to fetch profile: " + error);
}
});
</script>
+26
View File
@@ -0,0 +1,26 @@
<script setup>
import RegisterForm from '../components/Register/RegisterForm.vue';
import { useRouter } from 'vue-router';
import { register } from '../lib/api';
const router = useRouter();
const handleNewUser = async ({ username, password }) => {
try {
const response = await register(username, password);
alert(response.message || 'Registration successful! Please log in.');
// Redirect to login page
router.push('/login');
} catch (error) {
console.error("Registration error:", error);
alert("Registration error: " + error);
}
};
</script>
<template>
<div class="ts-container">
<RegisterForm @new-user="handleNewUser" />
</div>
</template>
-23
View File
@@ -1,23 +0,0 @@
<script setup>
import { onActivated, ref } from 'vue';
const users = ref([]);
onActivated(async () => {
const response = await fetch('/api/users');
if (!response.ok) {
users.value = [];
return;
}
const data = await response.json();
users.value = data;
});
</script>
<template>
<div class="ts-container">
<pre>{{ users }}</pre>
</div>
</template>
+10
View File
@@ -1,7 +1,17 @@
name = "ntu-awd-website" name = "ntu-awd-website"
pages_build_output_dir = "dist" pages_build_output_dir = "dist"
compatibility_date = "2025-04-12"
[vars]
hcaptcha_site_key = "a7340f48-b55e-4c56-8d96-2e70ce3423e0"
hcaptcha_secret_key = "ES_8b04993dc0004f59864d11bb1dc6a3bc"
JWT_SECRET = "7KH0adxP9mYrUYrEs0p_ccecRiaQf9IuxalS5r10QVI"
[[d1_databases]] [[d1_databases]]
binding = "DB" binding = "DB"
database_name = "awd-db" database_name = "awd-db"
database_id = "a2088769-aab4-44be-b24e-25c8762f0e80" database_id = "a2088769-aab4-44be-b24e-25c8762f0e80"
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "ntu-padn-mid-web"