Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions prisma/ERD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
39 changes: 25 additions & 14 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
99 changes: 99 additions & 0 deletions src/features/account/services/atcoder_verification.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<string> {
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<boolean> {
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<void> {
const user = await db.user.findUniqueOrThrow({ where: { username } });
await db.atCoderAccount.deleteMany({ where: { userId: user.id } });
}
4 changes: 2 additions & 2 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
47 changes: 7 additions & 40 deletions src/lib/services/users.ts
Original file line number Diff line number Diff line change
@@ -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 } });
}
Loading
Loading