diff --git a/compose.yaml b/compose.yaml index 95dd97cd8..481d09d77 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,6 +12,7 @@ services: - NODE_ENV=development - DATABASE_URL=postgresql://db_user:db_password@db:5432/test_db?pgbouncer=true&connection_limit=10&connect_timeout=60&statement_timeout=60000 # Note: Local server cannot start if port is set to db:6543. - DIRECT_URL=postgresql://db_user:db_password@db:5432/test_db + - CONFIRM_API_URL=https://prettyhappy.sakura.ne.jp/php_curl/index.php command: sleep infinity depends_on: - db diff --git a/prisma/ERD.md b/prisma/ERD.md index 6aae9fc99..1881d0bb4 100644 --- a/prisma/ERD.md +++ b/prisma/ERD.md @@ -111,14 +111,21 @@ ANALYSIS ANALYSIS String id "🗝️" String username Roles role - String atcoder_validation_code - String atcoder_username - Boolean atcoder_validation_status "❓" DateTime created_at DateTime updated_at } + "atcoder_account" { + String userId "🗝️" + String handle + Boolean isValidated + String validationCode + DateTime createdAt + DateTime updatedAt + } + + "session" { String id "🗝️" String user_id @@ -268,6 +275,7 @@ ANALYSIS ANALYSIS } "user" |o--|| "Roles" : "enum:role" + "atcoder_account" |o--|| user : "user" "session" }o--|| user : "user" "key" }o--|| user : "user" "task" |o--|| "ContestType" : "enum:contest_type" diff --git a/prisma/migrations/20260328002556_split_atcoder_account/migration.sql b/prisma/migrations/20260328002556_split_atcoder_account/migration.sql new file mode 100644 index 000000000..4be185271 --- /dev/null +++ b/prisma/migrations/20260328002556_split_atcoder_account/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable: must be created before data migration and column drop +CREATE TABLE "atcoder_account" ( + "userId" TEXT NOT NULL, + "handle" TEXT NOT NULL DEFAULT '', + "isValidated" BOOLEAN NOT NULL DEFAULT false, + "validationCode" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "atcoder_account_pkey" PRIMARY KEY ("userId") +); + +-- AddForeignKey +ALTER TABLE "atcoder_account" ADD CONSTRAINT "atcoder_account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- MigrateData: copy AtCoder fields from user to atcoder_account (only for users with a registered handle) +INSERT INTO "atcoder_account" ("userId", "handle", "isValidated", "validationCode", "createdAt", "updatedAt") +SELECT + "id", + "atcoder_username", + COALESCE("atcoder_validation_status", false), + "atcoder_validation_code", + NOW(), + NOW() +FROM "user" +WHERE "atcoder_username" != ''; + +-- AlterTable: drop AtCoder columns after data has been migrated +ALTER TABLE "user" DROP COLUMN "atcoder_username", +DROP COLUMN "atcoder_validation_code", +DROP COLUMN "atcoder_validation_status"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3ce3b9eb9..342fa041e 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,26 +40,37 @@ datasource db { // See: // https://lucia-auth.com/database-adapters/prisma/ model User { - id String @id @unique + id String @id @unique // here you can add custom fields for your user // e.g. name, email, username, roles, etc. - username String @unique - role Roles @default(USER) - atcoder_validation_code String @default("") - atcoder_username String @default("") - atcoder_validation_status Boolean? @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - auth_session Session[] - key Key[] - taskAnswer TaskAnswer[] - workBooks WorkBook[] - voteGrade VoteGrade[] + username String @unique + role Roles @default(USER) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + auth_session Session[] + key Key[] + taskAnswer TaskAnswer[] + workBooks WorkBook[] + voteGrade VoteGrade[] + atCoderAccount AtCoderAccount? @@map("user") } +model AtCoderAccount { + userId String @id + handle String @default("") + isValidated Boolean @default(false) + validationCode String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("atcoder_account") +} + // See: // https://www.prisma.io/docs/concepts/components/prisma-schema/data-model#defining-enums enum Roles { diff --git a/src/lib/components/UserAccountDeletionForm.svelte b/src/features/account/components/delete/AccountDeletionForm.svelte similarity index 94% rename from src/lib/components/UserAccountDeletionForm.svelte rename to src/features/account/components/delete/AccountDeletionForm.svelte index 57924ac0a..c41ea0deb 100644 --- a/src/lib/components/UserAccountDeletionForm.svelte +++ b/src/features/account/components/delete/AccountDeletionForm.svelte @@ -5,7 +5,7 @@ import ContainerWrapper from '$lib/components/ContainerWrapper.svelte'; import FormWrapper from '$lib/components/FormWrapper.svelte'; import LabelWrapper from '$lib/components/LabelWrapper.svelte'; - import WarningMessageOnDeletingAccount from '$lib/components/WarningMessageOnDeletingAccount.svelte'; + import WarningMessageOnDeletingAccount from './WarningMessageOnDeletingAccount.svelte'; interface Props { username: string; diff --git a/src/lib/components/WarningMessageOnDeletingAccount.svelte b/src/features/account/components/delete/WarningMessageOnDeletingAccount.svelte similarity index 100% rename from src/lib/components/WarningMessageOnDeletingAccount.svelte rename to src/features/account/components/delete/WarningMessageOnDeletingAccount.svelte diff --git a/src/lib/components/AtCoderUserValidationForm.svelte b/src/features/account/components/settings/AtCoderVerificationForm.svelte similarity index 100% rename from src/lib/components/AtCoderUserValidationForm.svelte rename to src/features/account/components/settings/AtCoderVerificationForm.svelte diff --git a/src/features/account/services/atcoder_verification.ts b/src/features/account/services/atcoder_verification.ts new file mode 100644 index 000000000..afcae5fb0 --- /dev/null +++ b/src/features/account/services/atcoder_verification.ts @@ -0,0 +1,99 @@ +import { default as db } from '$lib/server/database'; +import { sha256 } from '$lib/utils/hash'; + +const EXTERNAL_API_TIMEOUT_MS = 5000; + +/** Calls the external API to check if the validation code appears in the user's AtCoder affiliation. */ +async function confirmWithExternalApi(handle: string, validationCode: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), EXTERNAL_API_TIMEOUT_MS); + + try { + const baseUrl = process.env.CONFIRM_API_URL; + if (!baseUrl) { + throw new Error('CONFIRM_API_URL is not set.'); + } + const url = `${baseUrl}?user=${handle}`; + const response = await fetch(url, { signal: controller.signal }); + + if (!response.ok) { + throw new Error('Network response was not ok.'); + } + + try { + const jsonData = await response.json(); + return jsonData.contents?.some((item: string) => item === validationCode) ?? false; + } catch { + // Invalid JSON from external API — treat as unconfirmed + return false; + } + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Generates a SHA256 validation code, stores it in AtCoderAccount, and returns the code. + * Creates the AtCoderAccount record if it does not exist yet. + */ +export async function generate(username: string, handle: string): Promise { + const date = new Date().toISOString(); + const validationCode = await sha256(username + date); + + const user = await db.user.findUniqueOrThrow({ where: { username } }); + + await db.atCoderAccount.upsert({ + where: { userId: user.id }, + create: { userId: user.id, handle, validationCode, isValidated: false }, + update: { handle, validationCode, isValidated: false }, + }); + + return validationCode; +} + +/** + * Checks the external API and, if confirmed, marks the AtCoderAccount as validated. + * @returns true if validation succeeded, false otherwise. + */ +export async function validate(username: string): Promise { + const user = await db.user.findUniqueOrThrow({ + where: { username }, + include: { atCoderAccount: true }, + }); + + if (!user.atCoderAccount) { + return false; + } + + if (!user.atCoderAccount.validationCode) { + return false; + } + + let confirmed: boolean; + + try { + confirmed = await confirmWithExternalApi( + user.atCoderAccount.handle, + user.atCoderAccount.validationCode, + ); + } catch (error) { + throw new Error(`Failed to confirm AtCoder affiliation for ${username}: ${error}`); + } + + if (!confirmed) { + return false; + } + + await db.atCoderAccount.update({ + where: { userId: user.id }, + data: { validationCode: '', isValidated: true }, + }); + + return true; +} + +/** Deletes the AtCoderAccount record, effectively resetting the verification state. */ +export async function reset(username: string): Promise { + const user = await db.user.findUniqueOrThrow({ where: { username } }); + await db.atCoderAccount.deleteMany({ where: { userId: user.id } }); +} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index b65474c26..5fb9430a8 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -22,8 +22,8 @@ export const handle: Handle = async ({ event, resolve }) => { id: user.id, name: user.username, role: user.role, - atcoder_name: user.atcoder_username, - is_validated: user.atcoder_validation_status, + atcoder_name: user.atCoderAccount?.handle ?? '', + is_validated: user.atCoderAccount?.isValidated ?? null, }; } diff --git a/src/lib/services/users.ts b/src/lib/services/users.ts index c7dea4ddd..c59badddc 100644 --- a/src/lib/services/users.ts +++ b/src/lib/services/users.ts @@ -1,52 +1,19 @@ import { default as db } from '$lib/server/database'; -import type { User } from '@prisma/client'; export async function getUser(username: string) { - const user = await db.user.findUnique({ - where: { - username: username, - }, + return await db.user.findUnique({ + where: { username }, + include: { atCoderAccount: true }, }); - return user; } export async function getUserById(userId: string) { - const user = await db.user.findUnique({ - where: { - id: userId, - }, + return await db.user.findUnique({ + where: { id: userId }, + include: { atCoderAccount: true }, }); - return user; } export async function deleteUser(username: string) { - const user = await db.user.delete({ - where: { - username: username, - }, - }); - return user; -} - -export async function updateValicationCode( - username: string, - atcoder_id: string, - validationCode: string, -) { - try { - const user: User | null = await db.user.update({ - where: { - username: username, - }, - - data: { - atcoder_validation_code: validationCode, - atcoder_username: atcoder_id, - }, - }); - - return user; - } catch { - console.log('user update error'); - } + return await db.user.delete({ where: { username } }); } diff --git a/src/lib/services/validateApiService.ts b/src/lib/services/validateApiService.ts deleted file mode 100644 index db953ee9b..000000000 --- a/src/lib/services/validateApiService.ts +++ /dev/null @@ -1,95 +0,0 @@ -const confirmUrl = 'https://prettyhappy.sakura.ne.jp/php_curl/index.php'; - -import { sha256 } from '$lib/utils/hash'; -import { default as db } from '$lib/server/database'; - -async function confirm(atcoder_username: string, atcoder_validation_code: string) { - try { - const url = confirmUrl + '?user=' + atcoder_username; - const response = await fetch(url); - - if (!response.ok) { - throw new Error('Network response was not ok.'); - } - - const jsonData = await response.json(); - return jsonData.contents?.some((item: string) => item === atcoder_validation_code); - } catch (error) { - // Handle error - console.error('There was a problem fetching data:', error); - throw error; - } -} - -export async function generate(username: string, atcoder_username: string) { - //ハッシュを作る - const date = new Date().toISOString(); - const validationCode = await sha256(username + date); - console.log(username + validationCode); - - try { - const user = await db.user.update({ - where: { - username: username, - }, - data: { - atcoder_username: atcoder_username, - atcoder_validation_code: validationCode, - atcoder_validation_status: false, - }, - }); - return user.atcoder_validation_code; - } catch (error) { - throw new Error(`Failed to generate token: ${error}`); - } -} - -export async function validate(username: string) { - try { - const user = await db.user.findUniqueOrThrow({ - where: { - username: username, - }, - }); - - console.log(user); - const confirmResult = await confirm(user.atcoder_username, user.atcoder_validation_code); - console.log(user, confirmResult); - - if (confirmResult) { - await db.user.update({ - where: { - username: username, - }, - data: { - //atcoder_username: atcoder_username, - atcoder_validation_code: '', - atcoder_validation_status: true, - }, - }); - return true; - } else { - return false; - } - } catch (error) { - throw new Error(`Failed to validate user ${username}: ${error}`); - } -} - -export async function reset(username: string) { - try { - await db.user.update({ - where: { - username: username, - }, - data: { - atcoder_username: '', - atcoder_validation_code: '', - atcoder_validation_status: false, - }, - }); - return true; - } catch (error) { - throw new Error(`Failed to reset validation ${username}: ${error}`); - } -} diff --git a/src/routes/users/[username]/+page.server.ts b/src/routes/users/[username]/+page.server.ts index 0d6075b2c..c950e7020 100644 --- a/src/routes/users/[username]/+page.server.ts +++ b/src/routes/users/[username]/+page.server.ts @@ -41,7 +41,7 @@ export async function load({ locals, params }) { return { userId: user?.id as string, username: user?.username as string, - atcoder_username: user?.atcoder_username as string, + atcoder_username: user?.atCoderAccount?.handle ?? '', role: user?.role as Roles, isLoggedIn: (session?.user.userId === user?.id) as boolean, taskResults: taskResults, diff --git a/src/routes/users/edit/+page.server.ts b/src/routes/users/edit/+page.server.ts index d68a3b86b..797368bc5 100644 --- a/src/routes/users/edit/+page.server.ts +++ b/src/routes/users/edit/+page.server.ts @@ -2,12 +2,13 @@ import type { Roles } from '$lib/types/user'; import * as userService from '$lib/services/users'; -import * as validationService from '$lib/services/validateApiService'; +import * as verificationService from '$features/account/services/atcoder_verification'; import type { Actions } from './$types'; -import { redirect } from '@sveltejs/kit'; +import { redirect, fail } from '@sveltejs/kit'; +import { FORBIDDEN } from '$lib/constants/http-response-status-codes'; -export async function load({ locals }) { +export async function load({ locals, url }) { const session = await locals.auth.validate(); if (!session) { redirect(302, '/login'); @@ -21,11 +22,12 @@ export async function load({ locals }) { username: user?.username as string, role: user?.role as Roles, isLoggedIn: (session?.user.userId === user?.id) as boolean, - atcoder_username: user?.atcoder_username as string, - atcoder_validationcode: user?.atcoder_validation_code as string, - is_validated: user?.atcoder_validation_status as boolean, + atcoder_username: user?.atCoderAccount?.handle ?? '', + atcoder_validationcode: user?.atCoderAccount?.validationCode ?? '', + is_validated: user?.atCoderAccount?.isValidated ?? false, message_type: '', message: '', + openAtCoderTab: url.searchParams.get('tab') === 'atcoder', }; } catch (error) { console.error('Not found username: ', session?.user.username, error); @@ -35,78 +37,71 @@ export async function load({ locals }) { export const actions: Actions = { generate: async ({ request }) => { - console.log('users->actions->generate'); const formData = await request.formData(); const username = formData.get('username')?.toString() as string; const atcoder_username = formData.get('atcoder_username')?.toString() as string; - //console.log('ここにvalidationCodeを作成してデータベースに登録するコードを書きます'); - const validationCode = await validationService.generate(username, atcoder_username); + const validationCode = await verificationService.generate(username, atcoder_username); return { success: true, - username: username, - atcoder_username: atcoder_username, + username, + atcoder_username, atcoder_validationcode: validationCode, is_tab_atcoder: true, }; }, validate: async ({ request }) => { - console.log('users->actions->validate'); const formData = await request.formData(); const username = formData.get('username')?.toString() as string; - const atcoder_username = formData.get('atcoder_username')?.toString() as string; - const atcoder_validationcode = formData.get('atcoder_validationcode')?.toString() as string; - //console.log('validateを呼び、AtCoderの所属欄とAPI呼び出した結果が一致しているかを確認'); - const is_validated = await validationService.validate(username); + const is_validated = await verificationService.validate(username); return { success: is_validated, - user: { - username: username, - atcoder_username: atcoder_username, - atcoder_validationcode: atcoder_validationcode, - message_type: 'green', - message: 'Successfully validated.', - }, + message_type: 'green', + message: 'Successfully validated.', }; }, reset: async ({ request }) => { - console.log('users->actions->edit'); const formData = await request.formData(); const username = formData.get('username')?.toString() as string; const atcoder_username = formData.get('atcoder_username')?.toString() as string; - //console.log('AtCoderのユーザ名とValicationCodeをリセットする。'); - const validationCode = await validationService.reset(username); + await verificationService.reset(username); return { success: true, - username: username, - atcoder_username: atcoder_username, - atcoder_validationcode: validationCode, + username, + atcoder_username, + atcoder_validationcode: '', message_type: 'green', message: 'Successfully reset.', }; }, delete: async ({ request, locals }) => { - console.log('users->actions->delete'); + const session = await locals.auth.validate(); + if (!session) { + return fail(FORBIDDEN, { message: 'Not authenticated.' }); + } + const formData = await request.formData(); const username = formData.get('username')?.toString() as string; - const atcoder_username = formData.get('atcoder_username')?.toString() as string; + + if (session.user.username !== username) { + return fail(FORBIDDEN, { message: 'Not authorized.' }); + } await userService.deleteUser(username); locals.auth.setSession(null); // remove cookie return { success: true, - username: username, - atcoder_username: atcoder_username, - atcoder_validationcode: false, + username, + atcoder_validationcode: '', message_type: 'green', message: 'Successfully deleted.', }; diff --git a/src/routes/users/edit/+page.svelte b/src/routes/users/edit/+page.svelte index ee8dda122..7c761a298 100644 --- a/src/routes/users/edit/+page.svelte +++ b/src/routes/users/edit/+page.svelte @@ -1,8 +1,8 @@