diff options
Diffstat (limited to 'services/app/src')
55 files changed, 5599 insertions, 0 deletions
diff --git a/services/app/src/AlbCtl.php b/services/app/src/AlbCtl.php new file mode 100644 index 0000000..fb08aba --- /dev/null +++ b/services/app/src/AlbCtl.php @@ -0,0 +1,116 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross; + +use Nsfisis\Albatross\Database\Connection; +use Nsfisis\Albatross\Migrations\MigrationManager; +use Nsfisis\Albatross\Repositories\AnswerRepository; +use Nsfisis\Albatross\Repositories\QuizRepository; +use Nsfisis\Albatross\Repositories\UserRepository; + +final class AlbCtl +{ + private Connection $conn; + + public function __construct( + Config $config, + ) { + $this->conn = new Connection( + driver: 'pgsql', + host: $config->dbHost, + port: $config->dbPort, + name: $config->dbName, + user: $config->dbUser, + password: $config->dbPassword, + max_tries: 10, + sleep_sec: 3, + ); + } + + /** + * @param list<string> $argv + */ + public function run(array $argv): void + { + match ($argv[1] ?? 'help') { + 'migrate' => $this->runMigrate(), + 'promote' => $this->runPromote(), + 'deluser' => $this->runDeleteUser(), + 'delquiz' => $this->runDeleteQuiz(), + default => $this->runHelp(), + }; + } + + private function runMigrate(): void + { + $migration_manager = new MigrationManager($this->conn); + $migration_manager->execute(); + } + + private function runPromote(): void + { + echo "Username: "; + $username = trim((string)fgets(STDIN)); + + $user_repo = new UserRepository($this->conn); + $user = $user_repo->findByUsername($username); + if ($user === null) { + echo "User '$username' not found.\n"; + return; + } + $user_repo->update( + user_id: $user->user_id, + is_admin: true, + ); + } + + private function runDeleteUser(): void + { + echo "Username: "; + $username = trim((string)fgets(STDIN)); + + $user_repo = new UserRepository($this->conn); + $answer_repo = new AnswerRepository($this->conn); + + $this->conn->transaction(function () use ($user_repo, $answer_repo, $username) { + $user = $user_repo->findByUsername($username); + if ($user === null) { + echo "User '$username' not found.\n"; + return; + } + $answer_repo->deleteAllByUserId($user->user_id); + $user_repo->delete($user->user_id); + }); + // It is unnecessary to destroy existing sessions here because + // CurrentUserMiddleware will check whether the user exists or not. + } + + private function runDeleteQuiz(): void + { + echo "Quiz ID: "; + $quiz_id = (int)trim((string)fgets(STDIN)); + + $answer_repo = new AnswerRepository($this->conn); + $quiz_repo = new QuizRepository($this->conn); + + $this->conn->transaction(function () use ($answer_repo, $quiz_repo, $quiz_id) { + $answer_repo->deleteAllByQuizId($quiz_id); + $quiz_repo->delete($quiz_id); + }); + } + + private function runHelp(): void + { + echo <<<EOS + Usage: albctl <command> + + Commands: + migrate Run database migrations. + promote Promote a user to administrator. + deluser Delete a user. + delquiz Delete a quiz. + EOS; + } +} diff --git a/services/app/src/App.php b/services/app/src/App.php new file mode 100644 index 0000000..7b31e74 --- /dev/null +++ b/services/app/src/App.php @@ -0,0 +1,1015 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross; + +use DI\Bridge\Slim\Bridge as AppFactory; +use DI\Container; +use Middlewares\PhpSession; +use Nsfisis\Albatross\Auth\AuthProviderInterface; +use Nsfisis\Albatross\Auth\ForteeAuth; +use Nsfisis\Albatross\Database\Connection; +use Nsfisis\Albatross\Form\FormBase; +use Nsfisis\Albatross\Form\FormState; +use Nsfisis\Albatross\Form\FormSubmissionFailureException; +use Nsfisis\Albatross\Forms\AdminQuizEditForm; +use Nsfisis\Albatross\Forms\AdminQuizNewForm; +use Nsfisis\Albatross\Forms\AdminTestcaseEditForm; +use Nsfisis\Albatross\Forms\AdminTestcaseNewForm; +use Nsfisis\Albatross\Forms\AdminUserEditForm; +use Nsfisis\Albatross\Forms\AnswerNewForm; +use Nsfisis\Albatross\Forms\LoginForm; +use Nsfisis\Albatross\Middlewares\AdminRequiredMiddleware; +use Nsfisis\Albatross\Middlewares\AuthRequiredMiddleware; +use Nsfisis\Albatross\Middlewares\CacheControlPrivateMiddleware; +use Nsfisis\Albatross\Middlewares\CurrentUserMiddleware; +use Nsfisis\Albatross\Middlewares\TrailingSlash; +use Nsfisis\Albatross\Middlewares\TwigMiddleware; +use Nsfisis\Albatross\Models\User; +use Nsfisis\Albatross\Repositories\AnswerRepository; +use Nsfisis\Albatross\Repositories\QuizRepository; +use Nsfisis\Albatross\Repositories\TestcaseExecutionRepository; +use Nsfisis\Albatross\Repositories\TestcaseRepository; +use Nsfisis\Albatross\Repositories\UserRepository; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Slim\App as SlimApp; +use Slim\Csrf\Guard as CsrfGuard; +use Slim\Exception\HttpBadRequestException; +use Slim\Exception\HttpForbiddenException; +use Slim\Exception\HttpNotFoundException; +use Slim\Interfaces\RouteParserInterface; +use Slim\Routing\RouteCollectorProxy; +use Slim\Views\Twig; + +final class App +{ + private SlimApp $app; + + public function __construct( + private readonly Config $config, + ) { + $container = new Container(); + $container->set(AuthProviderInterface::class, new ForteeAuth(apiEndpoint: $config->forteeApiEndpoint)); + $container->set(Connection::class, function () use ($config) { + return new Connection( + driver: 'pgsql', + host: $config->dbHost, + port: $config->dbPort, + name: $config->dbName, + user: $config->dbUser, + password: $config->dbPassword, + ); + }); + $app = AppFactory::create($container); + $container->set(ResponseFactoryInterface::class, $app->getResponseFactory()); + $container->set(RouteParserInterface::class, $app->getRouteCollector()->getRouteParser()); + + $app->addRoutingMiddleware(); + + $app->setBasePath($this->config->basePath); + + $app->get('/', $this->handleQuizList(...))->setName('quiz_list'); + $app->get('/q/', $this->handleRedirectQuizList(...))->setName('redirect_quiz_list'); + + $app->get('/login/', $this->handleLogin(...))->setName('login'); + $app->post('/login/', $this->handleLoginPost(...))->setName('login_post'); + + $app->group('/q', function (RouteCollectorProxy $group) use ($app) { + $group->group('/{qslug}', function (RouteCollectorProxy $group) use ($app) { + $group->get('/', $this->handleQuizView(...))->setName('quiz_view'); + + $group->group('/a', function (RouteCollectorProxy $group) use ($app) { + $group->get('/', $this->handleAnswerList(...))->setName('answer_list'); + $group->get('/new/', $this->handleAnswerNew(...)) + ->add(AuthRequiredMiddleware::create($app, 'login')) + ->setName('answer_new'); + $group->post('/new/', $this->handleAnswerNewPost(...)) + ->add(AuthRequiredMiddleware::create($app, 'login')) + ->setName('answer_new_post'); + $group->get('/{anum:[1-9][0-9]*}/', $this->handleAnswerView(...))->setName('answer_view'); + }); + }); + }); + + $app->group('/admin', function (RouteCollectorProxy $group) { + $group->get('/', $this->handleAdminOverview(...))->setName('admin_overview'); + + $group->group('/u', function (RouteCollectorProxy $group) { + $group->get('/', $this->handleAdminUserList(...))->setName('admin_user_list'); + + $group->get('/{username}/', $this->handleAdminUserEdit(...))->setName('admin_user_edit'); + $group->post('/{username}/', $this->handleAdminUserEditPost(...))->setName('admin_user_edit_post'); + }); + + $group->group('/q', function (RouteCollectorProxy $group) { + $group->get('/', $this->handleAdminQuizList(...))->setName('admin_quiz_list'); + + $group->get('/new/', $this->handleAdminQuizNew(...))->setName('admin_quiz_new'); + $group->post('/new/', $this->handleAdminQuizNewPost(...))->setName('admin_quiz_new_post'); + + $group->group('/{qslug}', function (RouteCollectorProxy $group) { + $group->get('/', $this->handleAdminQuizEdit(...))->setName('admin_quiz_edit'); + $group->post('/', $this->handleAdminQuizEditPost(...))->setName('admin_quiz_edit_post'); + + $group->group('/a', function (RouteCollectorProxy $group) { + $group->get('/', $this->handleAdminAnswerList(...))->setName('admin_answer_list'); + $group->post('/rerun/', $this->handleAdminAnswerRerunAllAnswersPost(...))->setName('admin_answer_rerun_all_answers_post'); + + $group->group('/{anum:[1-9][0-9]*}', function (RouteCollectorProxy $group) { + $group->get('/', $this->handleAdminAnswerEdit(...))->setName('admin_answer_edit'); + $group->post('/rerun/all/', $this->handleAdminAnswerRerunAllTestcasesPost(...))->setName('admin_answer_rerun_all_testcases_post'); + $group->post('/rerun/{txid:[1-9][0-9]*}/', $this->handleAdminAnswerRerunSingleTestcasePost(...))->setName('admin_answer_rerun_single_testcase_post'); + }); + }); + + $group->group('/t', function (RouteCollectorProxy $group) { + $group->get('/', $this->handleAdminTestcaseList(...))->setName('admin_testcase_list'); + + $group->get('/new/', $this->handleAdminTestcaseNew(...))->setName('admin_testcase_new'); + $group->post('/new/', $this->handleAdminTestcaseNewPost(...))->setName('admin_testcase_new_post'); + + $group->group('/{tid:[1-9][0-9]*}', function (RouteCollectorProxy $group) { + $group->get('/', $this->handleAdminTestcaseEdit(...))->setName('admin_testcase_edit'); + $group->post('/', $this->handleAdminTestcaseEditPost(...))->setName('admin_testcase_edit_post'); + $group->post('/delete/', $this->handleAdminTestcaseDeletePost(...))->setName('admin_testcase_delete_post'); + }); + }); + }); + }); + }) + ->add(AdminRequiredMiddleware::create($app)) + ->add(AuthRequiredMiddleware::create($app, 'login')); + + $app->get('/api/answers/{aid:[1-9][0-9]*}/statuses', $this->handleApiAnswerStatuses(...)) + ->add(AuthRequiredMiddleware::create($app, 'login')) + ->setName('api_answer_statuses'); + $app->get('/api/quizzes/{qid:[1-9][0-9]*}/chart', $this->handleApiQuizChart(...)) + ->setName('api_quiz_chart'); + + $app->add(TwigMiddleware::class); + $app->add(new CacheControlPrivateMiddleware()); + $app->add(CurrentUserMiddleware::class); + $app->add(CsrfGuard::class); + $app->add(new PhpSession()); + $app->add((new TrailingSlash(true))->redirect($app->getResponseFactory())); + + $app->addErrorMiddleware( + displayErrorDetails: $config->displayErrors, + logErrors: true, + logErrorDetails: true, + logger: null, // TODO + ); + + $this->app = $app; + } + + public function run(): void + { + $this->app->run(); + } + + private function handleLogin( + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + UserRepository $userRepo, + AuthProviderInterface $authProvider, + ): ResponseInterface { + if ($request->getAttribute('current_user') !== null) { + return $this->makeRedirectResponse( + $response, + $request->getQueryParams()['to'] ?? $routeParser->urlFor('quiz_list'), + ); + } + + $form = new LoginForm( + null, + destination: $request->getQueryParams()['to'] ?? null, + routeParser: $routeParser, + userRepo: $userRepo, + authProvider: $authProvider, + ); + return $this->showForm($request, $response, 'login.html.twig', $form); + } + + private function handleLoginPost( + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + UserRepository $userRepo, + AuthProviderInterface $authProvider, + ): ResponseInterface { + if ($request->getAttribute('current_user') !== null) { + return $this->makeRedirectResponse( + $response, + $request->getQueryParams()['to'] ?? $routeParser->urlFor('quiz_list'), + ); + } + + $form = new LoginForm( + FormState::fromRequest($request), + destination: $request->getQueryParams()['to'] ?? null, + routeParser: $routeParser, + userRepo: $userRepo, + authProvider: $authProvider, + ); + return $this->submitForm($request, $response, 'login.html.twig', $form); + } + + private function handleQuizList( + ServerRequestInterface $request, + ResponseInterface $response, + QuizRepository $quizRepo, + ): ResponseInterface { + $quizzes = $quizRepo->listStarted(); + + return $this->render($request, $response, 'quiz_list.html.twig', [ + 'page_title' => '問題一覧', + 'quizzes' => $quizzes, + ]); + } + + private function handleRedirectQuizList( + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + ): ResponseInterface { + return $this->makeRedirectResponse($response, $routeParser->urlFor('quiz_list'), 301); + } + + private function handleQuizView( + string $qslug, + ServerRequestInterface $request, + ResponseInterface $response, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + ): ResponseInterface { + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null || !$quiz->isStarted()) { + throw new HttpNotFoundException($request); + } + if ($quiz->isRankingHidden()) { + $ranking = null; + } else { + $ranking = $answerRepo->getRanking($quiz->quiz_id, upto: 20); + } + + return $this->render($request, $response, 'quiz_view.html.twig', [ + 'page_title' => "問題 #{$quiz->quiz_id}", + 'quiz' => $quiz, + 'ranking' => $ranking, + 'is_ranking_hidden' => $quiz->isRankingHidden(), + 'is_open' => $quiz->isOpenToAnswer(), + ]); + } + + private function handleAnswerList( + string $qslug, + ServerRequestInterface $request, + ResponseInterface $response, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + ): ResponseInterface { + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null || !$quiz->isStarted()) { + throw new HttpNotFoundException($request); + } + if ($quiz->isRankingHidden()) { + $currentUser = $this->getCurrentUser($request); + if ($currentUser === null) { + $answers = []; + } else { + $answers = $answerRepo->listByQuizIdAndAuthorId($quiz->quiz_id, $currentUser->user_id); + } + } else { + $answers = $answerRepo->listByQuizId($quiz->quiz_id); + } + + return $this->render($request, $response, 'answer_list.html.twig', [ + 'page_title' => "問題 #{$quiz->quiz_id} - 回答一覧", + 'quiz' => $quiz, + 'answers' => $answers, + 'is_ranking_hidden' => $quiz->isRankingHidden(), + ]); + } + + private function handleAnswerNew( + string $qslug, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + TestcaseExecutionRepository $testcaseExecutionRepo, + Connection $conn, + ): ResponseInterface { + $currentUser = $this->getCurrentUser($request); + assert( + isset($currentUser), + 'The "current_user" attribute should be set because this route has AuthRequiredMiddleware', + ); + + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null || $quiz->isClosedToAnswer()) { + throw new HttpNotFoundException($request); + } + + $form = new AnswerNewForm( + null, + currentUser: $currentUser, + quiz: $quiz, + routeParser: $routeParser, + answerRepo: $answerRepo, + testcaseExecutionRepo: $testcaseExecutionRepo, + conn: $conn, + ); + return $this->showForm($request, $response, 'answer_new.html.twig', $form); + } + + private function handleAnswerNewPost( + string $qslug, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + TestcaseExecutionRepository $testcaseExecutionRepo, + Connection $conn, + ): ResponseInterface { + $currentUser = $this->getCurrentUser($request); + assert( + isset($currentUser), + 'The "current_user" attribute should be set because this route has AuthRequiredMiddleware', + ); + + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpBadRequestException($request); + } + + $form = new AnswerNewForm( + FormState::fromRequest($request), + currentUser: $currentUser, + quiz: $quiz, + routeParser: $routeParser, + answerRepo: $answerRepo, + testcaseExecutionRepo: $testcaseExecutionRepo, + conn: $conn, + ); + return $this->submitForm($request, $response, 'answer_new.html.twig', $form); + } + + private function handleAnswerView( + string $qslug, + string $anum, + ServerRequestInterface $request, + ResponseInterface $response, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + TestcaseExecutionRepository $testcaseExecutionRepo, + ): ResponseInterface { + $anum = (int)$anum; + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null || !$quiz->isStarted()) { + throw new HttpNotFoundException($request); + } + $answer = $answerRepo->findByQuizIdAndAnswerNumber($quiz->quiz_id, answer_number: $anum); + if ($answer === null) { + throw new HttpNotFoundException($request); + } + $currentUser = $this->getCurrentUser($request); + if ($quiz->isOpenToAnswer() && $answer->author_id !== $currentUser?->user_id) { + throw new HttpForbiddenException($request); + } + + return $this->render($request, $response, 'answer_view.html.twig', [ + 'page_title' => "問題 #{$quiz->quiz_id} - 回答 #{$answer->answer_number}", + 'quiz' => $quiz, + 'answer' => $answer, + 'testcase_executions' => $testcaseExecutionRepo->listByAnswerId($answer->answer_id), + ]); + } + + private function handleAdminOverview( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + return $this->render($request, $response, 'admin_overview.html.twig', [ + 'page_title' => '管理画面', + ]); + } + + private function handleAdminUserList( + ServerRequestInterface $request, + ResponseInterface $response, + UserRepository $userRepo, + ): ResponseInterface { + $users = $userRepo->listAll(); + + return $this->render($request, $response, 'admin_user_list.html.twig', [ + 'page_title' => '管理画面 - ユーザ一覧', + 'users' => $users, + ]); + } + + private function handleAdminUserEdit( + string $username, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + UserRepository $userRepo, + ): ResponseInterface { + $user = $userRepo->findByUsername($username); + if ($user === null) { + throw new HttpNotFoundException($request); + } + + $form = new AdminUserEditForm( + null, + user: $user, + routeParser: $routeParser, + userRepo: $userRepo, + ); + return $this->showForm($request, $response, 'admin_user_edit.html.twig', $form); + } + + private function handleAdminUserEditPost( + string $username, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + UserRepository $userRepo, + ): ResponseInterface { + $user = $userRepo->findByUsername($username); + if ($user === null) { + throw new HttpBadRequestException($request); + } + + $form = new AdminUserEditForm( + FormState::fromRequest($request), + user: $user, + routeParser: $routeParser, + userRepo: $userRepo, + ); + return $this->submitForm($request, $response, 'admin_user_edit.html.twig', $form); + } + + private function handleAdminQuizList( + ServerRequestInterface $request, + ResponseInterface $response, + QuizRepository $quizRepo, + ): ResponseInterface { + $quizzes = $quizRepo->listAll(); + + return $this->render($request, $response, 'admin_quiz_list.html.twig', [ + 'page_title' => '管理画面 - 問題一覧', + 'quizzes' => $quizzes, + ]); + } + + private function handleAdminQuizNew( + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + ): ResponseInterface { + $form = new AdminQuizNewForm( + null, + routeParser: $routeParser, + quizRepo: $quizRepo, + ); + return $this->showForm($request, $response, 'admin_quiz_new.html.twig', $form); + } + + private function handleAdminQuizNewPost( + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + ): ResponseInterface { + $form = new AdminQuizNewForm( + FormState::fromRequest($request), + routeParser: $routeParser, + quizRepo: $quizRepo, + ); + return $this->submitForm($request, $response, 'admin_quiz_new.html.twig', $form); + } + + private function handleAdminQuizEdit( + string $qslug, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + ): ResponseInterface { + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpNotFoundException($request); + } + + $form = new AdminQuizEditForm( + null, + quiz: $quiz, + routeParser: $routeParser, + quizRepo: $quizRepo, + ); + return $this->showForm($request, $response, 'admin_quiz_edit.html.twig', $form); + } + + private function handleAdminQuizEditPost( + string $qslug, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + ): ResponseInterface { + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpBadRequestException($request); + } + + $form = new AdminQuizEditForm( + FormState::fromRequest($request), + quiz: $quiz, + routeParser: $routeParser, + quizRepo: $quizRepo, + ); + return $this->submitForm($request, $response, 'admin_quiz_edit.html.twig', $form); + } + + private function handleAdminAnswerList( + string $qslug, + ServerRequestInterface $request, + ResponseInterface $response, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + ): ResponseInterface { + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpNotFoundException($request); + } + $answers = $answerRepo->listByQuizId($quiz->quiz_id); + + return $this->render($request, $response, 'admin_answer_list.html.twig', [ + 'page_title' => "管理画面 - 問題 #{$quiz->quiz_id} - 回答一覧", + 'quiz' => $quiz, + 'answers' => $answers, + ]); + } + + private function handleAdminAnswerEdit( + string $qslug, + string $anum, + ServerRequestInterface $request, + ResponseInterface $response, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + TestcaseExecutionRepository $testcaseExecutionRepo, + ): ResponseInterface { + $anum = (int)$anum; + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpNotFoundException($request); + } + $answer = $answerRepo->findByQuizIdAndAnswerNumber($quiz->quiz_id, answer_number: $anum); + if ($answer === null) { + throw new HttpNotFoundException($request); + } + $answerRepo->markAsPending($answer->answer_id); + $testcaseExecutions = $testcaseExecutionRepo->listByAnswerId($answer->answer_id); + + return $this->render($request, $response, 'admin_answer_edit.html.twig', [ + 'page_title' => "管理画面 - 問題 #{$quiz->quiz_id} - 回答 #{$answer->answer_number} - 編集", + 'quiz' => $quiz, + 'answer' => $answer, + 'testcase_executions' => $testcaseExecutions, + ]); + } + + private function handleAdminAnswerRerunAllAnswersPost( + string $qslug, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + TestcaseExecutionRepository $testcaseExecutionRepo, + Connection $conn, + ): ResponseInterface { + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpNotFoundException($request); + } + + $conn->transaction(function () use ($quiz, $answerRepo, $testcaseExecutionRepo) { + $answerRepo->markAllAsPending($quiz->quiz_id); + $testcaseExecutionRepo->markAllAsPendingByQuizId($quiz->quiz_id); + }); + + return $this->makeRedirectResponse($response, $routeParser->urlFor('admin_answer_list', ['qslug' => $qslug])); + } + + private function handleAdminAnswerRerunAllTestcasesPost( + string $qslug, + string $anum, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + TestcaseExecutionRepository $testcaseExecutionRepo, + Connection $conn, + ): ResponseInterface { + $anum = (int)$anum; + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpNotFoundException($request); + } + $answer = $answerRepo->findByQuizIdAndAnswerNumber($quiz->quiz_id, answer_number: $anum); + if ($answer === null) { + throw new HttpNotFoundException($request); + } + + $conn->transaction(function () use ($answer, $answerRepo, $testcaseExecutionRepo) { + $answerRepo->markAsPending($answer->answer_id); + $testcaseExecutionRepo->markAllAsPendingByAnswerId($answer->answer_id); + }); + + return $this->makeRedirectResponse($response, $routeParser->urlFor('admin_answer_list', ['qslug' => $qslug])); + } + + private function handleAdminAnswerRerunSingleTestcasePost( + string $qslug, + string $anum, + string $txid, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + TestcaseExecutionRepository $testcaseExecutionRepo, + Connection $conn, + ): ResponseInterface { + $anum = (int)$anum; + $txid = (int)$txid; + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpNotFoundException($request); + } + $answer = $answerRepo->findByQuizIdAndAnswerNumber($quiz->quiz_id, answer_number: $anum); + if ($answer === null) { + throw new HttpNotFoundException($request); + } + $ex = $testcaseExecutionRepo->findByAnswerIdAndTestcaseExecutionId(answer_id: $answer->answer_id, testcase_execution_id: $txid); + if ($ex === null) { + throw new HttpNotFoundException($request); + } + + $conn->transaction(function () use ($answer, $ex, $answerRepo, $testcaseExecutionRepo) { + $answerRepo->markAsPending($answer->answer_id); + $testcaseExecutionRepo->markAsPending($ex->testcase_execution_id); + }); + + return $this->makeRedirectResponse($response, $routeParser->urlFor('admin_answer_list', ['qslug' => $qslug])); + } + + private function handleAdminTestcaseList( + string $qslug, + ServerRequestInterface $request, + ResponseInterface $response, + QuizRepository $quizRepo, + TestcaseRepository $testcaseRepo, + ): ResponseInterface { + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpNotFoundException($request); + } + $testcases = $testcaseRepo->listByQuizId($quiz->quiz_id); + + return $this->render($request, $response, 'admin_testcase_list.html.twig', [ + 'page_title' => "管理画面 - 問題 #{$quiz->quiz_id} - テストケース一覧", + 'quiz' => $quiz, + 'testcases' => $testcases, + ]); + } + + private function handleAdminTestcaseNew( + string $qslug, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + TestcaseRepository $testcaseRepo, + TestcaseExecutionRepository $testcaseExecutionRepo, + Connection $conn, + ): ResponseInterface { + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpNotFoundException($request); + } + + $form = new AdminTestcaseNewForm( + null, + quiz: $quiz, + routeParser: $routeParser, + answerRepo: $answerRepo, + testcaseRepo: $testcaseRepo, + testcaseExecutionRepo: $testcaseExecutionRepo, + conn: $conn, + ); + return $this->showForm($request, $response, 'admin_testcase_new.html.twig', $form); + } + + private function handleAdminTestcaseNewPost( + string $qslug, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + TestcaseRepository $testcaseRepo, + TestcaseExecutionRepository $testcaseExecutionRepo, + Connection $conn, + ): ResponseInterface { + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpBadRequestException($request); + } + + $form = new AdminTestcaseNewForm( + FormState::fromRequest($request), + quiz: $quiz, + routeParser: $routeParser, + answerRepo: $answerRepo, + testcaseRepo: $testcaseRepo, + testcaseExecutionRepo: $testcaseExecutionRepo, + conn: $conn, + ); + return $this->submitForm($request, $response, 'admin_testcase_new.html.twig', $form); + } + + private function handleAdminTestcaseEdit( + string $qslug, + string $tid, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + TestcaseRepository $testcaseRepo, + TestcaseExecutionRepository $testcaseExecutionRepo, + Connection $conn, + ): ResponseInterface { + $tid = (int)$tid; + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpNotFoundException($request); + } + $testcase = $testcaseRepo->findByQuizIdAndTestcaseId($quiz->quiz_id, testcase_id: $tid); + if ($testcase === null) { + throw new HttpNotFoundException($request); + } + + $form = new AdminTestcaseEditForm( + null, + testcase: $testcase, + quiz: $quiz, + routeParser: $routeParser, + answerRepo: $answerRepo, + testcaseRepo: $testcaseRepo, + testcaseExecutionRepo: $testcaseExecutionRepo, + conn: $conn, + ); + return $this->showForm($request, $response, 'admin_testcase_edit.html.twig', $form); + } + + private function handleAdminTestcaseEditPost( + string $qslug, + string $tid, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + TestcaseRepository $testcaseRepo, + TestcaseExecutionRepository $testcaseExecutionRepo, + Connection $conn, + ): ResponseInterface { + $tid = (int)$tid; + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpNotFoundException($request); + } + $testcase = $testcaseRepo->findByQuizIdAndTestcaseId($quiz->quiz_id, testcase_id: $tid); + if ($testcase === null) { + throw new HttpNotFoundException($request); + } + + $form = new AdminTestcaseEditForm( + FormState::fromRequest($request), + testcase: $testcase, + quiz: $quiz, + routeParser: $routeParser, + answerRepo: $answerRepo, + testcaseRepo: $testcaseRepo, + testcaseExecutionRepo: $testcaseExecutionRepo, + conn: $conn, + ); + return $this->submitForm($request, $response, 'admin_testcase_edit.html.twig', $form); + } + + private function handleAdminTestcaseDeletePost( + string $qslug, + string $tid, + ServerRequestInterface $request, + ResponseInterface $response, + RouteParserInterface $routeParser, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + TestcaseRepository $testcaseRepo, + TestcaseExecutionRepository $testcaseExecutionRepo, + Connection $conn, + ): ResponseInterface { + $tid = (int)$tid; + $quiz = $quizRepo->findBySlug($qslug); + if ($quiz === null) { + throw new HttpNotFoundException($request); + } + $testcase = $testcaseRepo->findByQuizIdAndTestcaseId($quiz->quiz_id, testcase_id: $tid); + if ($testcase === null) { + throw new HttpNotFoundException($request); + } + + $conn->transaction(function () use ($testcase, $quiz, $answerRepo, $testcaseExecutionRepo, $testcaseRepo): void { + $answerRepo->markAllAsUpdateNeeded($quiz->quiz_id); + $testcaseExecutionRepo->deleteByTestcaseId($testcase->testcase_id); + $testcaseRepo->delete($testcase->testcase_id); + }); + + return $this->makeRedirectResponse($response, $routeParser->urlFor('admin_testcase_list', ['qslug' => $qslug])); + } + + private function handleApiAnswerStatuses( + string $aid, + ServerRequestInterface $request, + ResponseInterface $response, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + TestcaseExecutionRepository $testcaseExecutionRepo, + ): ResponseInterface { + $aid = (int)$aid; + $currentUser = $this->getCurrentUser($request); + assert( + isset($currentUser), + 'The "current_user" attribute should be set because this route has AuthRequiredMiddleware', + ); + $answer = $answerRepo->findById($aid); + if ($answer === null) { + throw new HttpNotFoundException($request); + } + $quiz = $quizRepo->findById($answer->quiz_id); + if ($quiz === null) { + throw new HttpNotFoundException($request); + } + if ($quiz->isOpenToAnswer() && $answer->author_id !== $currentUser->user_id) { + throw new HttpForbiddenException($request); + } + + $testcaseExecutions = $testcaseExecutionRepo->listByAnswerId($answer->answer_id); + + return $this->makeJsonResponse($response, [ + 'aggregated_status' => [ + 'label' => $answer->execution_status->label(), + 'show_loading_indicator' => $answer->execution_status->showLoadingIndicator(), + ], + 'testcase_executions' => array_map(fn ($ex) => [ + 'id' => $ex->testcase_execution_id, + 'status' => [ + 'label' => $ex->status->label(), + 'show_loading_indicator' => $ex->status->showLoadingIndicator(), + ], + 'stdout' => $ex->stdout, + 'stderr' => $ex->stderr, + ], $testcaseExecutions), + ])->withStatus(200); + } + + private function handleApiQuizChart( + string $qid, + ServerRequestInterface $request, + ResponseInterface $response, + QuizRepository $quizRepo, + AnswerRepository $answerRepo, + ): ResponseInterface { + $qid = (int)$qid; + $quiz = $quizRepo->findById($qid); + if ($quiz === null || !$quiz->isStarted()) { + return $this->makeJsonResponse($response, [ + 'error' => 'not_found', + ])->withStatus(404); + } + if ($quiz->isRankingHidden()) { + return $this->makeJsonResponse($response, [ + 'error' => 'forbidden', + ])->withStatus(403); + } + + $correctAnswers = $answerRepo->listAllCorrectAnswers($quiz->quiz_id); + + $stats = []; + foreach ($correctAnswers as $answer) { + if (!isset($stats[$answer->author_id])) { + $stats[$answer->author_id]['user'] = [ + 'name' => $answer->author_name, + 'is_admin' => $answer->author_is_admin, + ]; + } + $stats[$answer->author_id]['scores'][] = [ + 'submitted_at' => $answer->submitted_at->getTimestamp(), + 'code_size' => $answer->code_size, + ]; + } + usort($stats, function ($a, $b) { + $aBestScore = min(array_column($a['scores'], 'code_size')); + $bBestScore = min(array_column($b['scores'], 'code_size')); + return $aBestScore <=> $bBestScore; + }); + + return $this->makeJsonResponse($response, [ + 'stats' => array_values($stats), + ]) + ->withHeader('Cache-Control', 'max-age=10') + ->withStatus(200); + } + + private function makeJsonResponse(ResponseInterface $response, mixed $data): ResponseInterface + { + $payload = json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $response->getBody()->write($payload); + return $response->withHeader('Content-Type', 'application/json'); + } + + private function makeRedirectResponse(ResponseInterface $response, string $path, int $status = 303): ResponseInterface + { + return $response + ->withStatus($status) + ->withHeader('Location', $path); + } + + /** + * @param array<string, mixed> $context + */ + private function render( + ServerRequestInterface $request, + ResponseInterface $response, + string $template, + array $context, + ): ResponseInterface { + return Twig::fromRequest($request)->render( + $response, + $template, + $context + ['site_name' => $this->config->siteName], + ); + } + + private function showForm( + ServerRequestInterface $request, + ResponseInterface $response, + string $template, + FormBase $form, + ): ResponseInterface { + return $this->render($request, $response, $template, [ + 'page_title' => $form->pageTitle(), + 'form' => $form->toTemplateVars(), + ] + $form->getRenderContext()); + } + + private function submitForm( + ServerRequestInterface $request, + ResponseInterface $response, + string $template, + FormBase $form, + ): ResponseInterface { + try { + $form->submit(); + return $this->makeRedirectResponse($response, $form->redirectUrl()); + } catch (FormSubmissionFailureException $e) { + return $this->showForm($request, $response, $template, $form) + ->withStatus($e->getCode()); + } + } + + private function getCurrentUser(ServerRequestInterface $request): ?User + { + $currentUser = $request->getAttribute('current_user'); + assert( + $currentUser === null || $currentUser instanceof User, + 'The "current_user" attribute should be set by CurrentUserMiddleware if available', + ); + return $currentUser; + } +} diff --git a/services/app/src/Auth/AuthProviderInterface.php b/services/app/src/Auth/AuthProviderInterface.php new file mode 100644 index 0000000..19122f6 --- /dev/null +++ b/services/app/src/Auth/AuthProviderInterface.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Auth; + +interface AuthProviderInterface +{ + public function login(string $username, string $password): AuthenticationResult; +} diff --git a/services/app/src/Auth/AuthenticationResult.php b/services/app/src/Auth/AuthenticationResult.php new file mode 100644 index 0000000..08a5722 --- /dev/null +++ b/services/app/src/Auth/AuthenticationResult.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Auth; + +enum AuthenticationResult +{ + case Success; + case InvalidCredentials; + case InvalidJson; + case UnknownError; +} diff --git a/services/app/src/Auth/ForteeAuth.php b/services/app/src/Auth/ForteeAuth.php new file mode 100644 index 0000000..4f711be --- /dev/null +++ b/services/app/src/Auth/ForteeAuth.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Auth; + +final class ForteeAuth implements AuthProviderInterface +{ + public function __construct( + private string $apiEndpoint, + ) { + } + + public function login(string $username, string $password): AuthenticationResult + { + $query_params = [ + 'username' => $username, + 'password' => $password, + ]; + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'follow_location' => 0, + 'header' => [ + 'Content-type: application/x-www-form-urlencoded', + 'Accept: application/json', + ], + 'timeout' => 5.0, + 'content' => http_build_query($query_params), + ], + ]); + $result = file_get_contents( + $this->apiEndpoint . '/api/user/login', + context: $context, + ); + if ($result === false) { + return AuthenticationResult::UnknownError; + } + $result = json_decode($result, true); + if (!is_array($result)) { + return AuthenticationResult::InvalidJson; + } + $ok = ($result['loggedIn'] ?? null) === true; + if ($ok) { + return AuthenticationResult::Success; + } else { + return AuthenticationResult::InvalidCredentials; + } + } +} diff --git a/services/app/src/Commands/DeleteQuizCommand.php b/services/app/src/Commands/DeleteQuizCommand.php new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/services/app/src/Commands/DeleteQuizCommand.php diff --git a/services/app/src/Commands/MakeAdminCommand.php b/services/app/src/Commands/MakeAdminCommand.php new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/services/app/src/Commands/MakeAdminCommand.php diff --git a/services/app/src/Commands/MigrateCommand.php b/services/app/src/Commands/MigrateCommand.php new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/services/app/src/Commands/MigrateCommand.php diff --git a/services/app/src/Config.php b/services/app/src/Config.php new file mode 100644 index 0000000..16bf6b1 --- /dev/null +++ b/services/app/src/Config.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross; + +final class Config +{ + public function __construct( + public readonly string $basePath, + public readonly string $siteName, + public readonly bool $displayErrors, + public readonly string $dbHost, + public readonly int $dbPort, + public readonly string $dbName, + public readonly string $dbUser, + public readonly string $dbPassword, + public readonly string $forteeApiEndpoint, + ) { + } + + public static function fromEnvVars(): self + { + return new self( + basePath: self::getEnvVar('ALBATROSS_BASE_PATH'), + siteName: self::getEnvVar('ALBATROSS_SITE_NAME'), + displayErrors: self::getEnvVar('ALBATROSS_DISPLAY_ERRORS') === '1', + dbHost: self::getEnvVar('ALBATROSS_DB_HOST'), + dbPort: (int) self::getEnvVar('ALBATROSS_DB_PORT'), + dbName: self::getEnvVar('ALBATROSS_DB_NAME'), + dbUser: self::getEnvVar('ALBATROSS_DB_USER'), + dbPassword: self::getEnvVar('ALBATROSS_DB_PASSWORD'), + forteeApiEndpoint: self::getEnvVar('ALBATROSS_FORTEE_API_ENDPOINT'), + ); + } + + private static function getEnvVar(string $name): string + { + $value = getenv($name); + if ($value === false) { + throw new \RuntimeException("Environment variable \${$name} not set"); + } + return $value; + } +} diff --git a/services/app/src/Database/Connection.php b/services/app/src/Database/Connection.php new file mode 100644 index 0000000..01f46b2 --- /dev/null +++ b/services/app/src/Database/Connection.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Database; + +use Exception; +use LogicException; +use Nsfisis\Albatross\Sql\QueryBuilder; +use PDO; +use PDOException; + +final class Connection +{ + private readonly PDO $conn; + + public function __construct( + string $driver, + string $host, + int $port, + string $name, + string $user, + string $password, + int $max_tries = 10, + int $sleep_sec = 3, + ) { + if ($driver !== 'pgsql') { + throw new LogicException('Only pgsql is supported'); + } + $this->conn = self::tryConnect( + "$driver:host=$host;port=$port;dbname=$name;user=$user;password=$password", + $max_tries, + $sleep_sec, + ); + } + + public static function tryConnect( + string $dsn, + int $max_tries, + int $sleep_sec, + ): PDO { + $tries = 0; + while (true) { + try { + return self::connect($dsn); + } catch (PDOException $e) { + if ($max_tries <= $tries) { + throw $e; + } + sleep($sleep_sec); + } + $tries++; + } + } + + public function query(): QueryBuilder + { + return new QueryBuilder($this->conn); + } + + /** + * @template T + * @param callable(): T $fn + * @return T + */ + public function transaction(callable $fn): mixed + { + $this->conn->beginTransaction(); + try { + $result = $fn(); + $this->conn->commit(); + return $result; + } catch (Exception $e) { + $this->conn->rollBack(); + throw $e; + } + } + + /** + * @throws PDOException + */ + private static function connect(string $dsn): PDO + { + return new PDO( + dsn: $dsn, + options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_PERSISTENT => true, + ], + ); + } +} diff --git a/services/app/src/Exceptions/EntityValidationException.php b/services/app/src/Exceptions/EntityValidationException.php new file mode 100644 index 0000000..d4d958e --- /dev/null +++ b/services/app/src/Exceptions/EntityValidationException.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Exceptions; + +use RuntimeException; +use Throwable; + +final class EntityValidationException extends RuntimeException +{ + /** + * @param array<string, string> $errors + */ + public function __construct( + string $message = '', + int $code = 0, + ?Throwable $previous = null, + private readonly array $errors = [], + ) { + parent::__construct($message, $code, $previous); + } + + /** + * @return array<string, string> + */ + public function toFormErrors(): array + { + if (count($this->errors) === 0) { + return ['general' => $this->getMessage()]; + } else { + return $this->errors; + } + } +} diff --git a/services/app/src/Exceptions/InvalidSqlException.php b/services/app/src/Exceptions/InvalidSqlException.php new file mode 100644 index 0000000..fa53ca1 --- /dev/null +++ b/services/app/src/Exceptions/InvalidSqlException.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Exceptions; + +use LogicException; + +final class InvalidSqlException extends LogicException +{ +} diff --git a/services/app/src/Form/FormBase.php b/services/app/src/Form/FormBase.php new file mode 100644 index 0000000..cc29442 --- /dev/null +++ b/services/app/src/Form/FormBase.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Form; + +abstract class FormBase +{ + public function __construct( + protected readonly FormState $state, + ) { + } + + /** + * @return array{action: ?string, submit_label: string, items: list<FormItem>, state: array<string, string>, errors: array<string, string>} + */ + public function toTemplateVars(): array + { + return [ + 'action' => $this->action(), + 'submit_label' => $this->submitLabel(), + 'items' => $this->items(), + 'state' => $this->state->getParams(), + 'errors' => $this->state->getErrors(), + ]; + } + + abstract public function pageTitle(): string; + + abstract public function redirectUrl(): string; + + protected function action(): ?string + { + return null; + } + + abstract protected function submitLabel(): string; + + /** + * @return list<FormItem> + */ + abstract protected function items(): array; + + /** + * @return array<string, mixed> + */ + public function getRenderContext(): array + { + return []; + } + + abstract public function submit(): void; +} diff --git a/services/app/src/Form/FormItem.php b/services/app/src/Form/FormItem.php new file mode 100644 index 0000000..47fe9c9 --- /dev/null +++ b/services/app/src/Form/FormItem.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Form; + +final class FormItem +{ + public function __construct( + public readonly string $name, + public readonly string $type, + public readonly ?string $label = null, + public readonly bool $isRequired = false, + public readonly bool $isDisabled = false, + public readonly string $extra = '', + ) { + } +} diff --git a/services/app/src/Form/FormState.php b/services/app/src/Form/FormState.php new file mode 100644 index 0000000..e56e8f0 --- /dev/null +++ b/services/app/src/Form/FormState.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Form; + +use Psr\Http\Message\ServerRequestInterface; + +final class FormState +{ + /** + * @var array<string, string> + */ + private array $errors = []; + + /** + * @param array<string, string> $params + */ + public function __construct(private readonly array $params = []) + { + } + + public static function fromRequest(ServerRequestInterface $request): self + { + return new self((array)$request->getParsedBody()); + } + + /** + * @return array<string, string> + */ + public function getParams(): array + { + return $this->params; + } + + public function get(string $key): ?string + { + $value = $this->params[$key] ?? null; + if (isset($value)) { + return $key === 'password' ? $value : trim($value); + } else { + return null; + } + } + + /** + * @param array<string, string> $errors + */ + public function setErrors(array $errors): self + { + $this->errors = $errors; + return $this; + } + + /** + * @return array<string, string> + */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/services/app/src/Form/FormSubmissionFailureException.php b/services/app/src/Form/FormSubmissionFailureException.php new file mode 100644 index 0000000..0aa2c90 --- /dev/null +++ b/services/app/src/Form/FormSubmissionFailureException.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Form; + +use RuntimeException; +use Throwable; + +final class FormSubmissionFailureException extends RuntimeException +{ + public function __construct( + string $message = '', + int $code = 400, + Throwable $previous = null, + ) { + parent::__construct($message, $code, $previous); + } +} diff --git a/services/app/src/Forms/AdminQuizEditForm.php b/services/app/src/Forms/AdminQuizEditForm.php new file mode 100644 index 0000000..0fc382b --- /dev/null +++ b/services/app/src/Forms/AdminQuizEditForm.php @@ -0,0 +1,214 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Forms; + +use DateTimeImmutable; +use DateTimeZone; +use Nsfisis\Albatross\Exceptions\EntityValidationException; +use Nsfisis\Albatross\Form\FormBase; +use Nsfisis\Albatross\Form\FormItem; +use Nsfisis\Albatross\Form\FormState; +use Nsfisis\Albatross\Form\FormSubmissionFailureException; +use Nsfisis\Albatross\Models\Quiz; +use Nsfisis\Albatross\Repositories\QuizRepository; +use Slim\Interfaces\RouteParserInterface; + +final class AdminQuizEditForm extends FormBase +{ + public function __construct( + ?FormState $state, + private readonly Quiz $quiz, + private readonly RouteParserInterface $routeParser, + private readonly QuizRepository $quizRepo, + ) { + if (!isset($state)) { + $state = new FormState([ + 'title' => $quiz->title, + 'slug' => $quiz->slug, + 'description' => $quiz->description, + 'example_code' => $quiz->example_code, + 'birdie_code_size' => (string)$quiz->birdie_code_size, + 'started_at' => $quiz->started_at->setTimezone(new DateTimeZone('Asia/Tokyo'))->format('Y-m-d\TH:i'), + 'ranking_hidden_at' => $quiz->ranking_hidden_at->setTimezone(new DateTimeZone('Asia/Tokyo'))->format('Y-m-d\TH:i'), + 'finished_at' => $quiz->finished_at->setTimezone(new DateTimeZone('Asia/Tokyo'))->format('Y-m-d\TH:i'), + ]); + } + parent::__construct($state); + } + + public function pageTitle(): string + { + return "管理画面 - 問題 #{$this->quiz->quiz_id} - 編集"; + } + + public function redirectUrl(): string + { + return $this->routeParser->urlFor('admin_quiz_list'); + } + + protected function submitLabel(): string + { + return '保存'; + } + + /** + * @return list<FormItem> + */ + protected function items(): array + { + return [ + new FormItem( + name: 'title', + type: 'text', + label: 'タイトル', + isRequired: true, + ), + new FormItem( + name: 'slug', + type: 'text', + label: 'スラグ', + isRequired: true, + isDisabled: true, + ), + new FormItem( + name: 'description', + type: 'textarea', + label: '説明', + isRequired: true, + extra: 'rows="3" cols="80"', + ), + new FormItem( + name: 'example_code', + type: 'textarea', + label: '実装例', + isRequired: true, + extra: 'rows="10" cols="80"', + ), + new FormItem( + name: 'birdie_code_size', + type: 'text', + label: 'バーディになるコードサイズ (byte)', + ), + new FormItem( + name: 'started_at', + type: 'datetime-local', + label: '開始日時 (JST)', + isRequired: true, + ), + new FormItem( + name: 'ranking_hidden_at', + type: 'datetime-local', + label: 'ランキングが非表示になる日時 (JST)', + isRequired: true, + ), + new FormItem( + name: 'finished_at', + type: 'datetime-local', + label: '終了日時 (JST)', + isRequired: true, + ), + ]; + } + + /** + * @return array{quiz: Quiz} + */ + public function getRenderContext(): array + { + return [ + 'quiz' => $this->quiz, + ]; + } + + public function submit(): void + { + $title = $this->state->get('title') ?? ''; + $slug = $this->state->get('slug') ?? ''; + $description = $this->state->get('description') ?? ''; + $example_code = $this->state->get('example_code') ?? ''; + $birdie_code_size = $this->state->get('birdie_code_size') ?? ''; + $started_at = $this->state->get('started_at') ?? ''; + $ranking_hidden_at = $this->state->get('ranking_hidden_at') ?? ''; + $finished_at = $this->state->get('finished_at') ?? ''; + + $errors = []; + if ($birdie_code_size !== '' && !is_numeric($birdie_code_size)) { + $errors['birdie_code_size'] = '数値を入力してください'; + $birdie_code_size = ''; // dummy + } + if ($started_at === '') { + $errors['started_at'] = '開始日時は必須です'; + } else { + $started_at = DateTimeImmutable::createFromFormat( + 'Y-m-d\TH:i', + $started_at, + new DateTimeZone('Asia/Tokyo'), + ); + if ($started_at === false) { + $errors['started_at'] = '開始日時の形式が不正です'; + } else { + $started_at = $started_at->setTimezone(new DateTimeZone('UTC')); + } + } + if (!$started_at instanceof DateTimeImmutable) { + $started_at = new DateTimeImmutable('now', new DateTimeZone('UTC')); // dummy + } + if ($ranking_hidden_at === '') { + $errors['ranking_hidden_at'] = 'ランキングが非表示になる日時は必須です'; + } else { + $ranking_hidden_at = DateTimeImmutable::createFromFormat( + 'Y-m-d\TH:i', + $ranking_hidden_at, + new DateTimeZone('Asia/Tokyo'), + ); + if ($ranking_hidden_at === false) { + $errors['ranking_hidden_at'] = 'ランキングが非表示になる日時の形式が不正です'; + } else { + $ranking_hidden_at = $ranking_hidden_at->setTimezone(new DateTimeZone('UTC')); + } + } + if (!$ranking_hidden_at instanceof DateTimeImmutable) { + $ranking_hidden_at = new DateTimeImmutable('now', new DateTimeZone('UTC')); // dummy + } + if ($finished_at === '') { + $errors['finished_at'] = '終了日時は必須です'; + } else { + $finished_at = DateTimeImmutable::createFromFormat( + 'Y-m-d\TH:i', + $finished_at, + new DateTimeZone('Asia/Tokyo'), + ); + if ($finished_at === false) { + $errors['finished_at'] = '終了日時の形式が不正です'; + } else { + $finished_at = $finished_at->setTimezone(new DateTimeZone('UTC')); + } + } + if (!$finished_at instanceof DateTimeImmutable) { + $finished_at = new DateTimeImmutable('now', new DateTimeZone('UTC')); // dummy + } + + if (0 < count($errors)) { + $this->state->setErrors($errors); + throw new FormSubmissionFailureException(); + } + + try { + $this->quizRepo->update( + $this->quiz->quiz_id, + $title, + $description, + $example_code, + $birdie_code_size === '' ? null : (int)$birdie_code_size, + $started_at, + $ranking_hidden_at, + $finished_at, + ); + } catch (EntityValidationException $e) { + $this->state->setErrors($e->toFormErrors()); + throw new FormSubmissionFailureException(previous: $e); + } + } +} diff --git a/services/app/src/Forms/AdminQuizNewForm.php b/services/app/src/Forms/AdminQuizNewForm.php new file mode 100644 index 0000000..0038dc1 --- /dev/null +++ b/services/app/src/Forms/AdminQuizNewForm.php @@ -0,0 +1,192 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Forms; + +use DateTimeImmutable; +use DateTimeZone; +use Nsfisis\Albatross\Exceptions\EntityValidationException; +use Nsfisis\Albatross\Form\FormBase; +use Nsfisis\Albatross\Form\FormItem; +use Nsfisis\Albatross\Form\FormState; +use Nsfisis\Albatross\Form\FormSubmissionFailureException; +use Nsfisis\Albatross\Repositories\QuizRepository; +use Slim\Interfaces\RouteParserInterface; + +final class AdminQuizNewForm extends FormBase +{ + public function __construct( + ?FormState $state, + private readonly RouteParserInterface $routeParser, + private readonly QuizRepository $quizRepo, + ) { + if (!isset($state)) { + $state = new FormState(); + } + parent::__construct($state); + } + + public function pageTitle(): string + { + return '管理画面 - 問題作成'; + } + + public function redirectUrl(): string + { + return $this->routeParser->urlFor('admin_quiz_list'); + } + + protected function submitLabel(): string + { + return '作成'; + } + + /** + * @return list<FormItem> + */ + protected function items(): array + { + return [ + new FormItem( + name: 'title', + type: 'text', + label: 'タイトル', + isRequired: true, + ), + new FormItem( + name: 'slug', + type: 'text', + label: 'スラグ', + isRequired: true, + ), + new FormItem( + name: 'description', + type: 'textarea', + label: '説明', + isRequired: true, + extra: 'rows="3" cols="80"', + ), + new FormItem( + name: 'example_code', + type: 'textarea', + label: '実装例', + isRequired: true, + extra: 'rows="10" cols="80"', + ), + new FormItem( + name: 'birdie_code_size', + type: 'text', + label: 'バーディになるコードサイズ (byte)', + ), + new FormItem( + name: 'started_at', + type: 'datetime-local', + label: '開始日時 (JST)', + isRequired: true, + ), + new FormItem( + name: 'ranking_hidden_at', + type: 'datetime-local', + label: 'ランキングが非表示になる日時 (JST)', + isRequired: true, + ), + new FormItem( + name: 'finished_at', + type: 'datetime-local', + label: '終了日時 (JST)', + isRequired: true, + ), + ]; + } + + public function submit(): void + { + $title = $this->state->get('title') ?? ''; + $slug = $this->state->get('slug') ?? ''; + $description = $this->state->get('description') ?? ''; + $example_code = $this->state->get('example_code') ?? ''; + $birdie_code_size = $this->state->get('birdie_code_size') ?? ''; + $started_at = $this->state->get('started_at') ?? ''; + $ranking_hidden_at = $this->state->get('ranking_hidden_at') ?? ''; + $finished_at = $this->state->get('finished_at') ?? ''; + + $errors = []; + if ($birdie_code_size !== '' && !is_numeric($birdie_code_size)) { + $errors['birdie_code_size'] = '数値を入力してください'; + $birdie_code_size = ''; // dummy + } + if ($started_at === '') { + $errors['started_at'] = '開始日時は必須です'; + } else { + $started_at = DateTimeImmutable::createFromFormat( + 'Y-m-d\TH:i', + $started_at, + new DateTimeZone('Asia/Tokyo'), + ); + if ($started_at === false) { + $errors['started_at'] = '開始日時の形式が不正です'; + } else { + $started_at = $started_at->setTimezone(new DateTimeZone('UTC')); + } + } + if (!$started_at instanceof DateTimeImmutable) { + $started_at = new DateTimeImmutable('now', new DateTimeZone('UTC')); // dummy + } + if ($ranking_hidden_at === '') { + $errors['ranking_hidden_at'] = 'ランキングが非表示になる日時は必須です'; + } else { + $ranking_hidden_at = DateTimeImmutable::createFromFormat( + 'Y-m-d\TH:i', + $ranking_hidden_at, + new DateTimeZone('Asia/Tokyo'), + ); + if ($ranking_hidden_at === false) { + $errors['ranking_hidden_at'] = 'ランキングが非表示になる日時の形式が不正です'; + } else { + $ranking_hidden_at = $ranking_hidden_at->setTimezone(new DateTimeZone('UTC')); + } + } + if (!$ranking_hidden_at instanceof DateTimeImmutable) { + $ranking_hidden_at = new DateTimeImmutable('now', new DateTimeZone('UTC')); // dummy + } + if ($finished_at === '') { + $errors['finished_at'] = '終了日時は必須です'; + } else { + $finished_at = DateTimeImmutable::createFromFormat( + 'Y-m-d\TH:i', + $finished_at, + new DateTimeZone('Asia/Tokyo'), + ); + if ($finished_at === false) { + $errors['finished_at'] = '終了日時の形式が不正です'; + } else { + $finished_at = $finished_at->setTimezone(new DateTimeZone('UTC')); + } + } + if (!$finished_at instanceof DateTimeImmutable) { + $finished_at = new DateTimeImmutable('now', new DateTimeZone('UTC')); // dummy + } + + if (0 < count($errors)) { + $this->state->setErrors($errors); + throw new FormSubmissionFailureException(); + } + + try { + $this->quizRepo->create( + $title, + $slug, + $description, + $example_code, + $birdie_code_size === '' ? null : (int)$birdie_code_size, + $started_at, + $ranking_hidden_at, + $finished_at, + ); + } catch (EntityValidationException $e) { + $this->state->setErrors($e->toFormErrors()); + throw new FormSubmissionFailureException(previous: $e); + } + } +} diff --git a/services/app/src/Forms/AdminTestcaseEditForm.php b/services/app/src/Forms/AdminTestcaseEditForm.php new file mode 100644 index 0000000..aa87c8a --- /dev/null +++ b/services/app/src/Forms/AdminTestcaseEditForm.php @@ -0,0 +1,122 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Forms; + +use Nsfisis\Albatross\Database\Connection; +use Nsfisis\Albatross\Exceptions\EntityValidationException; +use Nsfisis\Albatross\Form\FormBase; +use Nsfisis\Albatross\Form\FormItem; +use Nsfisis\Albatross\Form\FormState; +use Nsfisis\Albatross\Form\FormSubmissionFailureException; +use Nsfisis\Albatross\Models\Quiz; +use Nsfisis\Albatross\Models\Testcase; +use Nsfisis\Albatross\Repositories\AnswerRepository; +use Nsfisis\Albatross\Repositories\TestcaseExecutionRepository; +use Nsfisis\Albatross\Repositories\TestcaseRepository; +use Slim\Interfaces\RouteParserInterface; + +final class AdminTestcaseEditForm extends FormBase +{ + public function __construct( + ?FormState $state, + private readonly Testcase $testcase, + private readonly Quiz $quiz, + private readonly RouteParserInterface $routeParser, + private readonly AnswerRepository $answerRepo, + private readonly TestcaseRepository $testcaseRepo, + private readonly TestcaseExecutionRepository $testcaseExecutionRepo, + private readonly Connection $conn, + ) { + if (!isset($state)) { + $state = new FormState([ + 'input' => $testcase->input, + 'expected_result' => $testcase->expected_result, + ]); + } + parent::__construct($state); + } + + public function pageTitle(): string + { + return "管理画面 - 問題 #{$this->quiz->quiz_id} - テストケース #{$this->testcase->testcase_id} - 編集"; + } + + public function redirectUrl(): string + { + return $this->routeParser->urlFor('admin_testcase_list', ['qslug' => $this->quiz->slug]); + } + + protected function submitLabel(): string + { + return '保存'; + } + + /** + * @return list<FormItem> + */ + protected function items(): array + { + return [ + new FormItem( + name: 'input', + type: 'textarea', + label: '標準入力', + extra: 'rows="10" cols="80"', + ), + new FormItem( + name: 'expected_result', + type: 'textarea', + label: '期待する出力', + isRequired: true, + extra: 'rows="10" cols="80"', + ), + ]; + } + + /** + * @return array{testcase: Testcase, quiz: Quiz} + */ + public function getRenderContext(): array + { + return [ + 'testcase' => $this->testcase, + 'quiz' => $this->quiz, + ]; + } + + public function submit(): void + { + $input = $this->state->get('input') ?? ''; + $expected_result = $this->state->get('expected_result') ?? ''; + + $errors = []; + if ($expected_result === '') { + $errors['expected_result'] = '期待する出力は必須です'; + } + if (0 < count($errors)) { + $this->state->setErrors($errors); + throw new FormSubmissionFailureException(); + } + + try { + $this->conn->transaction(function () use ($input, $expected_result): void { + $quiz_id = $this->quiz->quiz_id; + + $this->testcaseRepo->update( + testcase_id: $this->testcase->testcase_id, + input: $input, + expected_result: $expected_result, + ); + $this->answerRepo->markAllAsPending($quiz_id); + $this->testcaseExecutionRepo->markAllAsPendingByTestcaseId( + testcase_id: $this->testcase->testcase_id, + ); + }); + } catch (EntityValidationException $e) { + $this->state->setErrors($e->toFormErrors()); + throw new FormSubmissionFailureException(previous: $e); + } + } +} diff --git a/services/app/src/Forms/AdminTestcaseNewForm.php b/services/app/src/Forms/AdminTestcaseNewForm.php new file mode 100644 index 0000000..e925f8c --- /dev/null +++ b/services/app/src/Forms/AdminTestcaseNewForm.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Forms; + +use Nsfisis\Albatross\Database\Connection; +use Nsfisis\Albatross\Exceptions\EntityValidationException; +use Nsfisis\Albatross\Form\FormBase; +use Nsfisis\Albatross\Form\FormItem; +use Nsfisis\Albatross\Form\FormState; +use Nsfisis\Albatross\Form\FormSubmissionFailureException; +use Nsfisis\Albatross\Models\Quiz; +use Nsfisis\Albatross\Repositories\AnswerRepository; +use Nsfisis\Albatross\Repositories\TestcaseExecutionRepository; +use Nsfisis\Albatross\Repositories\TestcaseRepository; +use Slim\Interfaces\RouteParserInterface; + +final class AdminTestcaseNewForm extends FormBase +{ + public function __construct( + ?FormState $state, + private readonly Quiz $quiz, + private readonly RouteParserInterface $routeParser, + private readonly AnswerRepository $answerRepo, + private readonly TestcaseRepository $testcaseRepo, + private readonly TestcaseExecutionRepository $testcaseExecutionRepo, + private readonly Connection $conn, + ) { + if (!isset($state)) { + $state = new FormState(); + } + parent::__construct($state); + } + + public function pageTitle(): string + { + return "管理画面 - 問題 #{$this->quiz->quiz_id} - テストケース作成"; + } + + public function redirectUrl(): string + { + return $this->routeParser->urlFor('admin_testcase_list', ['qslug' => $this->quiz->slug]); + } + + protected function submitLabel(): string + { + return '作成'; + } + + /** + * @return list<FormItem> + */ + protected function items(): array + { + return [ + new FormItem( + name: 'input', + type: 'textarea', + label: '標準入力', + extra: 'rows="10" cols="80"', + ), + new FormItem( + name: 'expected_result', + type: 'textarea', + label: '期待する出力', + isRequired: true, + extra: 'rows="10" cols="80"', + ), + ]; + } + + public function submit(): void + { + $input = $this->state->get('input') ?? ''; + $expected_result = $this->state->get('expected_result') ?? ''; + + $errors = []; + if ($expected_result === '') { + $errors['expected_result'] = '期待する出力は必須です'; + } + if (0 < count($errors)) { + $this->state->setErrors($errors); + throw new FormSubmissionFailureException(); + } + + try { + $this->conn->transaction(function () use ($input, $expected_result): void { + $quiz_id = $this->quiz->quiz_id; + + $testcase_id = $this->testcaseRepo->create( + quiz_id: $quiz_id, + input: $input, + expected_result: $expected_result, + ); + $this->answerRepo->markAllAsPending($quiz_id); + $this->testcaseExecutionRepo->enqueueForAllAnswers( + quiz_id: $quiz_id, + testcase_id: $testcase_id, + ); + }); + } catch (EntityValidationException $e) { + $this->state->setErrors($e->toFormErrors()); + throw new FormSubmissionFailureException(previous: $e); + } + } +} diff --git a/services/app/src/Forms/AdminUserEditForm.php b/services/app/src/Forms/AdminUserEditForm.php new file mode 100644 index 0000000..1a17f6c --- /dev/null +++ b/services/app/src/Forms/AdminUserEditForm.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Forms; + +use Nsfisis\Albatross\Form\FormBase; +use Nsfisis\Albatross\Form\FormItem; +use Nsfisis\Albatross\Form\FormState; +use Nsfisis\Albatross\Models\User; +use Nsfisis\Albatross\Repositories\UserRepository; +use Slim\Interfaces\RouteParserInterface; + +final class AdminUserEditForm extends FormBase +{ + public function __construct( + ?FormState $state, + private readonly User $user, + private readonly RouteParserInterface $routeParser, + private readonly UserRepository $userRepo, + ) { + if (!isset($state)) { + $state = new FormState([ + 'username' => $user->username, + 'is_admin' => $user->is_admin ? 'on' : '', + ]); + } + parent::__construct($state); + } + + public function pageTitle(): string + { + return "管理画面 - ユーザ {$this->user->username} 編集"; + } + + public function redirectUrl(): string + { + return $this->routeParser->urlFor('admin_user_list'); + } + + protected function submitLabel(): string + { + return '保存'; + } + + /** + * @return list<FormItem> + */ + protected function items(): array + { + return [ + new FormItem( + name: 'username', + type: 'text', + label: 'ユーザ名', + isDisabled: true, + ), + new FormItem( + name: 'is_admin', + type: 'checkbox', + label: '管理者', + ), + ]; + } + + /** + * @return array{user: User} + */ + public function getRenderContext(): array + { + return [ + 'user' => $this->user, + ]; + } + + public function submit(): void + { + $is_admin = $this->state->get('is_admin') === 'on'; + + $this->userRepo->update( + $this->user->user_id, + is_admin: $is_admin, + ); + } +} diff --git a/services/app/src/Forms/AnswerNewForm.php b/services/app/src/Forms/AnswerNewForm.php new file mode 100644 index 0000000..a07a172 --- /dev/null +++ b/services/app/src/Forms/AnswerNewForm.php @@ -0,0 +1,116 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Forms; + +use Nsfisis\Albatross\Database\Connection; +use Nsfisis\Albatross\Exceptions\EntityValidationException; +use Nsfisis\Albatross\Form\FormBase; +use Nsfisis\Albatross\Form\FormItem; +use Nsfisis\Albatross\Form\FormState; +use Nsfisis\Albatross\Form\FormSubmissionFailureException; +use Nsfisis\Albatross\Models\Answer; +use Nsfisis\Albatross\Models\Quiz; +use Nsfisis\Albatross\Models\User; +use Nsfisis\Albatross\Repositories\AnswerRepository; +use Nsfisis\Albatross\Repositories\TestcaseExecutionRepository; +use Slim\Interfaces\RouteParserInterface; + +final class AnswerNewForm extends FormBase +{ + private ?Answer $answer = null; + + public function __construct( + ?FormState $state, + private readonly User $currentUser, + private readonly Quiz $quiz, + private readonly RouteParserInterface $routeParser, + private readonly AnswerRepository $answerRepo, + private readonly TestcaseExecutionRepository $testcaseExecutionRepo, + private readonly Connection $conn, + ) { + if (!isset($state)) { + $state = new FormState(); + } + parent::__construct($state); + } + + public function pageTitle(): string + { + return "問題 #{$this->quiz->quiz_id} - 提出"; + } + + public function redirectUrl(): string + { + $answer = $this->answer; + assert(isset($answer)); + return $this->routeParser->urlFor( + 'answer_view', + ['qslug' => $this->quiz->slug, 'anum' => "$answer->answer_number"], + ); + } + + protected function submitLabel(): string + { + return '投稿'; + } + + /** + * @return list<FormItem> + */ + protected function items(): array + { + return [ + new FormItem( + name: 'code', + type: 'textarea', + label: 'コード', + isRequired: true, + extra: 'rows="3" cols="80"', + ), + ]; + } + + /** + * @return array{quiz: Quiz, is_closed: bool} + */ + public function getRenderContext(): array + { + return [ + 'quiz' => $this->quiz, + 'is_closed' => $this->quiz->isClosedToAnswer(), + ]; + } + + public function submit(): void + { + if ($this->quiz->isClosedToAnswer()) { + $this->state->setErrors(['general' => 'この問題の回答は締め切られました']); + throw new FormSubmissionFailureException(); + } + + $code = $this->state->get('code') ?? ''; + + try { + $answer_id = $this->conn->transaction(function () use ($code) { + $answer_id = $this->answerRepo->create( + $this->quiz->quiz_id, + $this->currentUser->user_id, + $code, + ); + $this->testcaseExecutionRepo->enqueueForSingleAnswer( + $answer_id, + $this->quiz->quiz_id, + ); + return $answer_id; + }); + } catch (EntityValidationException $e) { + $this->state->setErrors($e->toFormErrors()); + throw new FormSubmissionFailureException(previous: $e); + } + $answer = $this->answerRepo->findById($answer_id); + assert(isset($answer)); + $this->answer = $answer; + } +} diff --git a/services/app/src/Forms/LoginForm.php b/services/app/src/Forms/LoginForm.php new file mode 100644 index 0000000..26f6ad1 --- /dev/null +++ b/services/app/src/Forms/LoginForm.php @@ -0,0 +1,116 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Forms; + +use Nsfisis\Albatross\Auth\AuthenticationResult; +use Nsfisis\Albatross\Auth\AuthProviderInterface; +use Nsfisis\Albatross\Exceptions\EntityValidationException; +use Nsfisis\Albatross\Form\FormBase; +use Nsfisis\Albatross\Form\FormItem; +use Nsfisis\Albatross\Form\FormState; +use Nsfisis\Albatross\Form\FormSubmissionFailureException; +use Nsfisis\Albatross\Repositories\UserRepository; +use Slim\Interfaces\RouteParserInterface; + +final class LoginForm extends FormBase +{ + public function __construct( + ?FormState $state, + private readonly ?string $destination, + private readonly RouteParserInterface $routeParser, + private readonly UserRepository $userRepo, + private readonly AuthProviderInterface $authProvider, + ) { + if (!isset($state)) { + $state = new FormState(); + } + parent::__construct($state); + } + + public function pageTitle(): string + { + return 'ログイン'; + } + + public function redirectUrl(): string + { + return $this->destination ?? $this->routeParser->urlFor('quiz_list'); + } + + protected function submitLabel(): string + { + return 'ログイン'; + } + + /** + * @return list<FormItem> + */ + protected function items(): array + { + return [ + new FormItem( + name: 'username', + type: 'text', + label: 'ユーザ名', + isRequired: true, + ), + new FormItem( + name: 'password', + type: 'password', + label: 'パスワード', + isRequired: true, + ), + ]; + } + + public function submit(): void + { + $username = $this->state->get('username') ?? ''; + $password = $this->state->get('password') ?? ''; + + $this->validate($username, $password); + + $authResult = $this->authProvider->login($username, $password); + if ($authResult === AuthenticationResult::InvalidCredentials) { + $this->state->setErrors(['general' => 'ユーザ名またはパスワードが異なります']); + throw new FormSubmissionFailureException(code: 403); + } elseif ($authResult === AuthenticationResult::InvalidJson || $authResult === AuthenticationResult::UnknownError) { + throw new FormSubmissionFailureException(code: 500); + } else { + $user = $this->userRepo->findByUsername($username); + if ($user === null) { + try { + $user_id = $this->userRepo->create( + $username, + is_admin: true, // TODO + ); + } catch (EntityValidationException $e) { + $this->state->setErrors($e->toFormErrors()); + throw new FormSubmissionFailureException(previous: $e); + } + $_SESSION['user_id'] = $user_id; + } else { + $_SESSION['user_id'] = $user->user_id; + } + } + } + + private function validate(string $username, string $password): void + { + $errors = []; + if (strlen($username) < 1) { + $errors['username'] = 'ユーザ名は必須です'; + } + + if (strlen($password) < 1) { + $errors['password'] = 'パスワードは必須です'; + } + + if (count($errors) > 0) { + $this->state->setErrors($errors); + throw new FormSubmissionFailureException(code: 400); + } + } +} diff --git a/services/app/src/JobWorker.php b/services/app/src/JobWorker.php new file mode 100644 index 0000000..18e09ac --- /dev/null +++ b/services/app/src/JobWorker.php @@ -0,0 +1,189 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross; + +use Nsfisis\Albatross\Database\Connection; +use Nsfisis\Albatross\Models\AggregatedExecutionStatus; +use Nsfisis\Albatross\Models\Answer; +use Nsfisis\Albatross\Models\ExecutionStatus; +use Nsfisis\Albatross\Models\TestcaseExecution; +use Nsfisis\Albatross\Repositories\AnswerRepository; +use Nsfisis\Albatross\Repositories\TestcaseExecutionRepository; +use Nsfisis\Albatross\Repositories\TestcaseRepository; +use Nsfisis\Albatross\SandboxExec\ExecutionResult; +use Nsfisis\Albatross\SandboxExec\ExecutorClient; + +final class JobWorker +{ + private Connection $conn; + private AnswerRepository $answerRepo; + private TestcaseRepository $testcaseRepo; + private TestcaseExecutionRepository $testcaseExecutionRepo; + private ExecutorClient $executorClient; + + public function __construct( + Config $config, + ) { + $this->conn = new Connection( + driver: 'pgsql', + host: $config->dbHost, + port: $config->dbPort, + name: $config->dbName, + user: $config->dbUser, + password: $config->dbPassword, + max_tries: 10, + sleep_sec: 3, + ); + $this->answerRepo = new AnswerRepository($this->conn); + $this->testcaseRepo = new TestcaseRepository($this->conn); + $this->testcaseExecutionRepo = new TestcaseExecutionRepository($this->conn); + + $this->executorClient = new ExecutorClient( + 'http://albatross-sandbox-exec:8888', + timeoutMsec: 10 * 1000, + ); + } + + public function run(): void + { + // @phpstan-ignore-next-line + while (true) { + $task = $this->tryGetNextTask(); + if (isset($task)) { + $this->process($task); + } else { + $this->sleep(); + } + } + } + + private function process(Answer|TestcaseExecution $task): void + { + if ($task instanceof Answer) { + $this->updateAnswerAggregatedExecutionStatus($task, null); + } else { + $this->executeTestcase($task); + } + } + + private function tryGetNextTask(): Answer|TestcaseExecution|null + { + $answer = $this->answerRepo->tryGetNextUpdateNeededAnswer(); + if ($answer !== null) { + return $answer; + } + $ex = $this->testcaseExecutionRepo->tryGetNextPendingTestcaseExecution(); + return $ex; + } + + /** + * @param ?array{int, ExecutionStatus} $statusUpdate + */ + private function updateAnswerAggregatedExecutionStatus( + Answer $answer, + ?array $statusUpdate, + ): void { + $statuses = $this->testcaseExecutionRepo->getStatuses($answer->answer_id); + if ($statusUpdate !== null) { + [$updatedExId, $newStatus] = $statusUpdate; + $statuses[$updatedExId] = $newStatus; + } + + $pending_or_running_count = 0; + $ac_count = 0; + foreach ($statuses as $ex_id => $status) { + match ($status) { + ExecutionStatus::AC => $ac_count++, + ExecutionStatus::Pending, ExecutionStatus::Running => $pending_or_running_count++, + default => null, + }; + } + + $aggregatedStatus = match (true) { + $ac_count === count($statuses) => AggregatedExecutionStatus::OK, + $pending_or_running_count !== 0 => AggregatedExecutionStatus::Pending, + default => AggregatedExecutionStatus::Failed, + }; + $this->answerRepo->updateExecutionStatus($answer->answer_id, $aggregatedStatus); + } + + private function executeTestcase(TestcaseExecution $ex): void + { + $answer = $this->answerRepo->findById($ex->answer_id); + if ($answer === null) { + $this->testcaseExecutionRepo->update( + $ex->testcase_execution_id, + ExecutionStatus::IE, + '', + 'Failed to get the corresponding answer', + ); + return; + } + + $testcase = $this->testcaseRepo->findByQuizIdAndTestcaseId( + $answer->quiz_id, + $ex->testcase_id, + ); + if ($testcase === null) { + $this->testcaseExecutionRepo->update( + $ex->testcase_execution_id, + ExecutionStatus::IE, + '', + 'Failed to get the corresponding testcase', + ); + return; + } + + $result = $this->executeCode($answer->code, $testcase->input); + if ($result->status === ExecutionStatus::AC) { + $status = self::verifyResult($testcase->expected_result, $result->stdout) + ? ExecutionStatus::AC + : ExecutionStatus::WA; + } else { + $status = $result->status; + } + + $this->conn->transaction(function () use ($ex, $status, $result, $answer) { + $this->updateAnswerAggregatedExecutionStatus( + $answer, + [$ex->testcase_execution_id, $status], + ); + $this->testcaseExecutionRepo->update( + $ex->testcase_execution_id, + $status, + $result->stdout, + $result->stderr, + ); + }); + } + + private function executeCode(string $code, string $input): ExecutionResult + { + return $this->executorClient->execute( + code: Answer::normalizeCode($code), + input: self::normalizeInput($input), + ); + } + + private function sleep(): void + { + sleep(1); + } + + private static function verifyResult(string $expected, string $actual): bool + { + return self::normalizeOutput($expected) === self::normalizeOutput($actual); + } + + private static function normalizeInput(string $s): string + { + return trim(str_replace(["\r\n", "\r"], "\n", $s)) . "\n"; + } + + private static function normalizeOutput(string $s): string + { + return trim(str_replace(["\r\n", "\r"], "\n", $s)); + } +} diff --git a/services/app/src/Middlewares/AdminRequiredMiddleware.php b/services/app/src/Middlewares/AdminRequiredMiddleware.php new file mode 100644 index 0000000..dc81b42 --- /dev/null +++ b/services/app/src/Middlewares/AdminRequiredMiddleware.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Middlewares; + +use LogicException; +use Nsfisis\Albatross\Models\User; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Slim\App; + +final class AdminRequiredMiddleware implements MiddlewareInterface +{ + private function __construct( + private readonly ResponseFactoryInterface $responseFactory, + ) { + } + + public static function create(App $app): self + { + return new self($app->getResponseFactory()); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $current_user = $request->getAttribute('current_user'); + if (!$current_user instanceof User) { + throw new LogicException('The route that has this middleware must have the CurrentUserMiddleware before this one'); + } + + if (!$current_user->is_admin) { + $response = $this->responseFactory->createResponse(403); + $response->getBody()->write('Forbidden'); + return $response->withHeader('Content-Type', 'text/plain'); + } + + return $handler->handle($request); + } +} diff --git a/services/app/src/Middlewares/AuthRequiredMiddleware.php b/services/app/src/Middlewares/AuthRequiredMiddleware.php new file mode 100644 index 0000000..1985a0c --- /dev/null +++ b/services/app/src/Middlewares/AuthRequiredMiddleware.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Middlewares; + +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Slim\App; + +final class AuthRequiredMiddleware implements MiddlewareInterface +{ + private function __construct( + private readonly ResponseFactoryInterface $responseFactory, + private readonly string $loginPath, + ) { + } + + public static function create( + App $app, + string $loginRouteName, + ): self { + return new self( + $app->getResponseFactory(), + $app->getRouteCollector()->getRouteParser()->urlFor($loginRouteName), + ); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $current_user = $request->getAttribute('current_user'); + if ($current_user === null) { + return $this->responseFactory + ->createResponse(302) + ->withHeader('Location', $this->loginPath . "?to=" . urlencode($request->getUri()->getPath())); + } + + return $handler->handle($request); + } +} diff --git a/services/app/src/Middlewares/CacheControlPrivateMiddleware.php b/services/app/src/Middlewares/CacheControlPrivateMiddleware.php new file mode 100644 index 0000000..4372d5c --- /dev/null +++ b/services/app/src/Middlewares/CacheControlPrivateMiddleware.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Middlewares; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; + +final class CacheControlPrivateMiddleware implements MiddlewareInterface +{ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $response = $handler->handle($request); + if ($request->getAttribute('current_user') !== null) { + return $response->withHeader('Cache-Control', 'private'); + } else { + return $response; + } + } +} diff --git a/services/app/src/Middlewares/CurrentUserMiddleware.php b/services/app/src/Middlewares/CurrentUserMiddleware.php new file mode 100644 index 0000000..a58a327 --- /dev/null +++ b/services/app/src/Middlewares/CurrentUserMiddleware.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Middlewares; + +use Nsfisis\Albatross\Repositories\UserRepository; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; + +final class CurrentUserMiddleware implements MiddlewareInterface +{ + public function __construct( + private UserRepository $userRepo, + ) { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $request = $this->setCurrentUserAttribute($request); + return $handler->handle($request); + } + + private function setCurrentUserAttribute(ServerRequestInterface $request): ServerRequestInterface + { + if (session_status() !== PHP_SESSION_ACTIVE) { + return $request; + } + $user_id = $_SESSION['user_id'] ?? null; + if ($user_id === null) { + return $request; + } + assert(is_int($user_id) || (is_string($user_id) && is_numeric($user_id))); + $user_id = (int) $user_id; + $user = $this->userRepo->findById($user_id); + if ($user === null) { + return $request; + } + return $request->withAttribute('current_user', $user); + } +} diff --git a/services/app/src/Middlewares/TrailingSlash.php b/services/app/src/Middlewares/TrailingSlash.php new file mode 100644 index 0000000..cd0f333 --- /dev/null +++ b/services/app/src/Middlewares/TrailingSlash.php @@ -0,0 +1,113 @@ +<?php + +declare(strict_types=1); + +// phpcs:disable +/* + * Based on https://github.com/middlewares/trailing-slash + * + * Original license: + * + * The MIT License (MIT) + * + * Copyright (c) 2019 + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +// phpcs:enable + +namespace Nsfisis\Albatross\Middlewares; + +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; + +class TrailingSlash implements MiddlewareInterface +{ + /** + * @var bool Add or remove the slash + */ + private $trailingSlash; + + /** + * @var ResponseFactoryInterface|null + */ + private $responseFactory; + + /** + * Configure whether add or remove the slash. + */ + public function __construct(bool $trailingSlash = false) + { + $this->trailingSlash = $trailingSlash; + } + + /** + * Whether returns a 301 response to the new path. + */ + public function redirect(ResponseFactoryInterface $responseFactory): self + { + $this->responseFactory = $responseFactory; + + return $this; + } + + /** + * Process a request and return a response. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $uri = $request->getUri(); + $path = $this->normalize($uri->getPath()); + + if (isset($this->responseFactory) && ($uri->getPath() !== $path)) { + return $this->responseFactory->createResponse(301) + ->withHeader('Location', $path); + } + + return $handler->handle($request->withUri($uri->withPath($path))); + } + + /** + * Normalize the trailing slash. + */ + private function normalize(string $path): string + { + if ($path === '') { + return '/'; + } + if (str_contains($path, '/api/')) { + return $path; + } + + if (strlen($path) > 1) { + if ($this->trailingSlash) { + if (substr($path, -1) !== '/' && pathinfo($path, PATHINFO_EXTENSION) === '') { + return $path . '/'; + } + } else { + return rtrim($path, '/'); + } + } + + return $path; + } +} diff --git a/services/app/src/Middlewares/TwigMiddleware.php b/services/app/src/Middlewares/TwigMiddleware.php new file mode 100644 index 0000000..5b950ce --- /dev/null +++ b/services/app/src/Middlewares/TwigMiddleware.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Middlewares; + +use Nsfisis\Albatross\Twig\CsrfExtension; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Slim\App; +use Slim\Views\Twig; +use Slim\Views\TwigMiddleware as SlimTwigMiddleware; + +final class TwigMiddleware implements MiddlewareInterface +{ + private readonly SlimTwigMiddleware $wrapped; + + public function __construct(App $app, CsrfExtension $csrf_extension) + { + // TODO: + // $twig = Twig::create(__DIR__ . '/../../templates', ['cache' => __DIR__ . '/../../twig-cache']); + $twig = Twig::create(__DIR__ . '/../../templates', ['cache' => false]); + $twig->addExtension($csrf_extension); + $this->wrapped = SlimTwigMiddleware::create($app, $twig); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $this->wrapped->process($request, $handler); + } +} diff --git a/services/app/src/Migrations/MigrationManager.php b/services/app/src/Migrations/MigrationManager.php new file mode 100644 index 0000000..88073db --- /dev/null +++ b/services/app/src/Migrations/MigrationManager.php @@ -0,0 +1,248 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Migrations; + +use Nsfisis\Albatross\Database\Connection; + +final class MigrationManager +{ + public function __construct( + private readonly Connection $conn, + ) { + } + + public function execute(): void + { + $this->conn->transaction(function () { + $version = $this->fetchSchemaVersion(); + while (method_exists($this, "execute$version")) { + $method = "execute$version"; + // @phpstan-ignore-next-line + $this->$method(); + $this->conn + ->query() + ->insert('migrations') + ->values([]) + ->execute(); + $version++; + } + }); + } + + private function fetchSchemaVersion(): int + { + $this->conn->query()->schema(<<<EOSQL + CREATE TABLE IF NOT EXISTS migrations ( + migration_id SERIAL PRIMARY KEY + ); + EOSQL); + $result = $this->conn + ->query() + ->select('migrations') + ->fields(['COALESCE(MAX(migration_id), 0) + 1 AS schema_version']) + ->first() + ->execute(); + assert(isset($result['schema_version'])); + return (int) $result['schema_version']; + } + + /** + * Create the initial schema. + */ + private function execute1(): void + { + $this->conn->query()->schema(<<<EOSQL + CREATE TABLE IF NOT EXISTS quizzes ( + quiz_id SERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + title TEXT NOT NULL, + summary TEXT NOT NULL, + input_description TEXT NOT NULL, + output_description TEXT NOT NULL, + expected_result TEXT NOT NULL, + example_code TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS answers ( + answer_id SERIAL PRIMARY KEY, + quiz_id INTEGER NOT NULL, + answer_number INTEGER NOT NULL, + submitted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + author_name TEXT NOT NULL, + code TEXT NOT NULL, + code_size INTEGER NOT NULL, + execution_status INTEGER NOT NULL, + execution_stdout TEXT, + execution_stderr TEXT, + UNIQUE (quiz_id, answer_number), + FOREIGN KEY (quiz_id) REFERENCES quizzes (quiz_id) + ); + EOSQL); + } + + /** + * Add "slug" column to quizzes table. + */ + private function execute2(): void + { + $this->conn->query()->schema(<<<EOSQL + ALTER TABLE quizzes ADD COLUMN slug VARCHAR(32); + UPDATE quizzes SET slug = title; + ALTER TABLE quizzes ALTER COLUMN slug SET NOT NULL; + ALTER TABLE quizzes ADD CONSTRAINT uniq_slug UNIQUE (slug); + EOSQL); + } + + /** + * Add "birdie_code_size" column to quizzes table. + */ + private function execute3(): void + { + $this->conn->query()->schema(<<<EOSQL + ALTER TABLE quizzes ADD COLUMN birdie_code_size INTEGER; + EOSQL); + } + + /** + * Remove "input_description" and "summary" columns from quizzes table. Rename "output_description" to "description". + */ + private function execute4(): void + { + $this->conn->query()->schema(<<<EOSQL + ALTER TABLE quizzes DROP COLUMN input_description; + ALTER TABLE quizzes DROP COLUMN summary; + ALTER TABLE quizzes RENAME COLUMN output_description TO description; + EOSQL); + } + + /** + * Add "started_at", "answers_hidden_at" and "finished_at" columns to quizzes table. + */ + private function execute5(): void + { + $this->conn->query()->schema(<<<EOSQL + ALTER TABLE quizzes ADD COLUMN started_at TIMESTAMP; + ALTER TABLE quizzes ADD COLUMN answers_hidden_at TIMESTAMP; + ALTER TABLE quizzes ADD COLUMN finished_at TIMESTAMP; + EOSQL); + } + + /** + * Create users table. + */ + private function execute6(): void + { + $this->conn->query()->schema(<<<EOSQL + CREATE TABLE IF NOT EXISTS users ( + user_id SERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL, + password_hash VARCHAR(256) NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + UNIQUE (username) + ); + EOSQL); + } + + /** + * Migrate from author_name to author_id in answers table. + */ + private function execute7(): void + { + $this->conn->query()->schema(<<<EOSQL + ALTER TABLE answers DROP COLUMN author_name; + TRUNCATE TABLE answers; + ALTER TABLE answers ADD COLUMN author_id INTEGER NOT NULL; + ALTER TABLE answers ADD CONSTRAINT fk_author_id FOREIGN KEY (author_id) REFERENCES users (user_id); + EOSQL); + } + + /** + * Rename answers_hidden_at to ranking_hidden_at in quizzes table. + */ + private function execute8(): void + { + $this->conn->query()->schema(<<<EOSQL + ALTER TABLE quizzes RENAME COLUMN answers_hidden_at TO ranking_hidden_at; + EOSQL); + } + + /** + * Make "started_at", "ranking_hidden_at" and "finished_at" columns not null. + */ + private function execute9(): void + { + $this->conn->query()->schema(<<<EOSQL + UPDATE quizzes SET started_at = CURRENT_TIMESTAMP; + UPDATE quizzes SET ranking_hidden_at = CURRENT_TIMESTAMP; + UPDATE quizzes SET finished_at = CURRENT_TIMESTAMP; + ALTER TABLE quizzes ALTER COLUMN started_at SET NOT NULL; + ALTER TABLE quizzes ALTER COLUMN ranking_hidden_at SET NOT NULL; + ALTER TABLE quizzes ALTER COLUMN finished_at SET NOT NULL; + EOSQL); + } + + /** + * Remove "password_hash" column from "users" table. + */ + private function execute10(): void + { + $this->conn->query()->schema(<<<EOSQL + ALTER TABLE users DROP COLUMN password_hash; + EOSQL); + } + + /** + * Implement multi-testcases. + * + * - Create "testcases" table and "testcase_executions" table. + * - Remove "expected_result" column from "quizzes" table. + * - Remove "execution_stdout" and "execution_stderr" columns from "answers" table. + */ + private function execute11(): void + { + $this->conn->query()->schema(<<<EOSQL + CREATE TABLE IF NOT EXISTS testcases ( + testcase_id SERIAL PRIMARY KEY, + quiz_id INTEGER NOT NULL, + input TEXT NOT NULL, + expected_result TEXT NOT NULL, + FOREIGN KEY (quiz_id) REFERENCES quizzes (quiz_id) + ); + CREATE TABLE IF NOT EXISTS testcase_executions ( + testcase_execution_id SERIAL PRIMARY KEY, + testcase_id INTEGER NOT NULL, + answer_id INTEGER NOT NULL, + status INTEGER NOT NULL, + stdout TEXT, + stderr TEXT, + FOREIGN KEY (testcase_id) REFERENCES testcases (testcase_id), + FOREIGN KEY (answer_id) REFERENCES answers (answer_id) + ); + + INSERT INTO testcases (quiz_id, input, expected_result) + SELECT quiz_id, '', expected_result FROM quizzes; + ALTER TABLE quizzes DROP COLUMN expected_result; + + INSERT INTO testcase_executions (testcase_id, answer_id, status, stdout, stderr) + SELECT testcases.testcase_id, answers.answer_id, answers.execution_status, answers.execution_stdout, answers.execution_stderr + FROM answers + INNER JOIN testcases ON testcases.quiz_id = answers.quiz_id; + ALTER TABLE answers DROP COLUMN execution_stdout; + ALTER TABLE answers DROP COLUMN execution_stderr; + EOSQL); + } + + /** + * Migrate value of "execution_status" in "answers" table. + */ + private function execute12(): void + { + $this->conn->query()->schema(<<<EOSQL + UPDATE answers SET execution_status = 1 WHERE execution_status = 0 OR execution_status = 1; + UPDATE answers SET execution_status = 2 WHERE execution_status BETWEEN 2 AND 5; + UPDATE answers SET execution_status = 3 WHERE execution_status = 6; + EOSQL); + } +} diff --git a/services/app/src/Models/AggregatedExecutionStatus.php b/services/app/src/Models/AggregatedExecutionStatus.php new file mode 100644 index 0000000..82b19e4 --- /dev/null +++ b/services/app/src/Models/AggregatedExecutionStatus.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Models; + +enum AggregatedExecutionStatus: string +{ + case UpdateNeeded = 'UpdateNeeded'; + case Pending = 'Pending'; + case Failed = 'Failed'; + case OK = 'OK'; + + public function label(): string + { + return match ($this) { + self::UpdateNeeded => '実行待機中', + self::Pending => '実行待機中', + self::Failed => '失敗', + self::OK => 'OK', + }; + } + + public function showLoadingIndicator(): bool + { + return match ($this) { + self::UpdateNeeded => true, + self::Pending => true, + self::Failed => false, + self::OK => false, + }; + } + + public function toInt(): int + { + return match ($this) { + self::UpdateNeeded => 0, + self::Pending => 1, + self::Failed => 2, + self::OK => 3, + }; + } + + public static function fromInt(int $n): self + { + // @phpstan-ignore-next-line + return match ($n) { + 0 => self::UpdateNeeded, + 1 => self::Pending, + 2 => self::Failed, + 3 => self::OK, + }; + } +} diff --git a/services/app/src/Models/Answer.php b/services/app/src/Models/Answer.php new file mode 100644 index 0000000..6cb8006 --- /dev/null +++ b/services/app/src/Models/Answer.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Models; + +use DateTimeImmutable; +use Nsfisis\Albatross\Exceptions\EntityValidationException; + +final class Answer +{ + public function __construct( + public readonly int $answer_id, + public readonly int $quiz_id, + public readonly int $answer_number, + public readonly DateTimeImmutable $submitted_at, + public readonly int $author_id, + public readonly string $code, + public readonly int $code_size, + public readonly AggregatedExecutionStatus $execution_status, + public readonly ?string $author_name, // joined + public readonly ?bool $author_is_admin, // joined + ) { + } + + public static function create( + int $quiz_id, + int $author_id, + string $code, + ): self { + self::validate($quiz_id, $author_id, $code); + $answer = new self( + answer_id: 0, + quiz_id: $quiz_id, + answer_number: 0, + submitted_at: new DateTimeImmutable(), // dummy + author_id: $author_id, + code: $code, + code_size: strlen(self::normalizeCode($code)), // not mb_strlen + execution_status: AggregatedExecutionStatus::Pending, + author_name: null, + author_is_admin: null, + ); + return $answer; + } + + private static function validate( + int $quiz_id, + int $author_id, + string $code, + ): void { + $errors = []; + if (strlen($code) <= 0) { + $errors['code'] = 'コードを入力してください'; + } + if (10 * 1024 <= strlen($code)) { + $errors['code'] = 'コードが長すぎます。10 KiB 未満まで縮めてください'; + } + + if (0 < count($errors)) { + throw new EntityValidationException(errors: $errors); + } + } + + public static function normalizeCode(string $code): string + { + return preg_replace('/^\s*<\?(?:php\b)?\s*/', '', str_replace(["\r\n", "\r"], "\n", $code)) ?? $code; + } +} diff --git a/services/app/src/Models/ExecutionStatus.php b/services/app/src/Models/ExecutionStatus.php new file mode 100644 index 0000000..5ff1c9d --- /dev/null +++ b/services/app/src/Models/ExecutionStatus.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Models; + +enum ExecutionStatus: string +{ + case Pending = 'Pending'; + case Running = 'Running'; + case IE = 'IE'; + case RE = 'RE'; + case WA = 'WA'; + case TLE = 'TLE'; + case AC = 'AC'; + + public function label(): string + { + return match ($this) { + self::Pending => '実行待機中', + self::Running => '実行中', + self::IE => '内部エラー', + self::RE => '実行時エラー', + self::WA => '不正解', + self::TLE => '時間制限超過', + self::AC => 'OK', + }; + } + + public function showLoadingIndicator(): bool + { + return match ($this) { + self::Pending => true, + self::Running => true, + self::IE => false, + self::RE => false, + self::WA => false, + self::TLE => false, + self::AC => false, + }; + } + + public function toInt(): int + { + return match ($this) { + self::Pending => 0, + self::Running => 1, + self::IE => 2, + self::RE => 3, + self::WA => 4, + self::TLE => 5, + self::AC => 6, + }; + } + + public static function fromInt(int $n): self + { + // @phpstan-ignore-next-line + return match ($n) { + 0 => self::Pending, + 1 => self::Running, + 2 => self::IE, + 3 => self::RE, + 4 => self::WA, + 5 => self::TLE, + 6 => self::AC, + }; + } + + public static function fromString(string $s): self + { + // @phpstan-ignore-next-line + return match ($s) { + 'Pending' => self::Pending, + 'Running' => self::Running, + 'IE' => self::IE, + 'RE' => self::RE, + 'WA' => self::WA, + 'TLE' => self::TLE, + 'AC' => self::AC, + }; + } +} diff --git a/services/app/src/Models/Quiz.php b/services/app/src/Models/Quiz.php new file mode 100644 index 0000000..5546646 --- /dev/null +++ b/services/app/src/Models/Quiz.php @@ -0,0 +1,133 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Models; + +use DateTimeImmutable; +use DateTimeZone; +use Nsfisis\Albatross\Exceptions\EntityValidationException; + +final class Quiz +{ + public function __construct( + public readonly int $quiz_id, + public readonly DateTimeImmutable $created_at, + public readonly DateTimeImmutable $started_at, + public readonly DateTimeImmutable $ranking_hidden_at, + public readonly DateTimeImmutable $finished_at, + public readonly string $title, + public readonly string $slug, + public readonly string $description, + public readonly string $example_code, + public readonly ?int $birdie_code_size, + ) { + } + + public function isStarted(?DateTimeImmutable $now = null): bool + { + if ($now === null) { + $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); + } + return $this->started_at <= $now; + } + + public function isFinished(?DateTimeImmutable $now = null): bool + { + if ($now === null) { + $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); + } + return $this->finished_at <= $now; + } + + public function isOpenToAnswer(?DateTimeImmutable $now = null): bool + { + return $this->isStarted($now) && !$this->isFinished($now); + } + + public function isClosedToAnswer(?DateTimeImmutable $now = null): bool + { + return !$this->isOpenToAnswer($now); + } + + public function isRankingHidden(?DateTimeImmutable $now = null): bool + { + if ($now === null) { + $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); + } + return $this->ranking_hidden_at <= $now && !$this->isFinished($now); + } + + public static function create( + string $title, + string $slug, + string $description, + string $example_code, + ?int $birdie_code_size, + DateTimeImmutable $started_at, + DateTimeImmutable $ranking_hidden_at, + DateTimeImmutable $finished_at, + ): self { + self::validate( + $title, + $slug, + $description, + $example_code, + $birdie_code_size, + $started_at, + $ranking_hidden_at, + $finished_at, + ); + $quiz = new self( + quiz_id: 0, + created_at: new DateTimeImmutable(), // dummy + started_at: $started_at, + ranking_hidden_at: $ranking_hidden_at, + finished_at: $finished_at, + title: $title, + slug: $slug, + description: $description, + example_code: $example_code, + birdie_code_size: $birdie_code_size, + ); + return $quiz; + } + + public static function validate( + string $title, + string $slug, + string $description, + string $example_code, + ?int $birdie_code_size, + DateTimeImmutable $started_at, + DateTimeImmutable $ranking_hidden_at, + DateTimeImmutable $finished_at, + ): void { + $errors = []; + if (strlen($slug) < 1) { + $errors['slug'] = 'スラグは必須です'; + } + if (32 < strlen($slug)) { + $errors['slug'] = 'スラグは32文字以下である必要があります'; + } + if (strlen($description) < 1) { + $errors['description'] = '説明は必須です'; + } + if (strlen($example_code) < 1) { + $errors['example_code'] = '実装例は必須です'; + } + if ($birdie_code_size !== null && $birdie_code_size < 1) { + $errors['birdie_code_size'] = 'バーディになるコードサイズは 1 byte 以上である必要があります'; + } + if ($ranking_hidden_at < $started_at) { + $errors['ranking_hidden_at'] = 'ランキングが非表示になる日時は開始日時より後である必要があります'; + } + if ($finished_at < $ranking_hidden_at) { + $errors['finished_at'] = '終了日時はランキングが非表示になる日時より後である必要があります'; + } + + if (0 < count($errors)) { + throw new EntityValidationException(errors: $errors); + } + } +} diff --git a/services/app/src/Models/Testcase.php b/services/app/src/Models/Testcase.php new file mode 100644 index 0000000..68ee891 --- /dev/null +++ b/services/app/src/Models/Testcase.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Models; + +final class Testcase +{ + public function __construct( + public readonly int $testcase_id, + public readonly int $quiz_id, + public readonly string $input, + public readonly string $expected_result, + ) { + } + + public static function create( + int $quiz_id, + string $input, + string $expected_result, + ): self { + return new self( + testcase_id: 0, + quiz_id: $quiz_id, + input: $input, + expected_result: $expected_result, + ); + } +} diff --git a/services/app/src/Models/TestcaseExecution.php b/services/app/src/Models/TestcaseExecution.php new file mode 100644 index 0000000..63d2d6a --- /dev/null +++ b/services/app/src/Models/TestcaseExecution.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Models; + +final class TestcaseExecution +{ + public function __construct( + public readonly int $testcase_execution_id, + public readonly int $testcase_id, + public readonly int $answer_id, + public readonly ExecutionStatus $status, + public readonly ?string $stdout, + public readonly ?string $stderr, + ) { + } + + public static function create( + int $testcase_id, + int $answer_id, + ): self { + return new self( + testcase_execution_id: 0, + testcase_id: $testcase_id, + answer_id: $answer_id, + status: ExecutionStatus::Pending, + stdout: null, + stderr: null, + ); + } +} diff --git a/services/app/src/Models/User.php b/services/app/src/Models/User.php new file mode 100644 index 0000000..c80e661 --- /dev/null +++ b/services/app/src/Models/User.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Models; + +final class User +{ + public function __construct( + public readonly int $user_id, + public readonly string $username, + public readonly bool $is_admin, + ) { + } + + public static function create( + string $username, + bool $is_admin, + ): self { + return new self( + user_id: 0, + username: $username, + is_admin: $is_admin, + ); + } +} diff --git a/services/app/src/Repositories/AnswerRepository.php b/services/app/src/Repositories/AnswerRepository.php new file mode 100644 index 0000000..1798084 --- /dev/null +++ b/services/app/src/Repositories/AnswerRepository.php @@ -0,0 +1,277 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Repositories; + +use DateTimeImmutable; +use Nsfisis\Albatross\Database\Connection; +use Nsfisis\Albatross\Exceptions\EntityValidationException; +use Nsfisis\Albatross\Models\AggregatedExecutionStatus; +use Nsfisis\Albatross\Models\Answer; +use Nsfisis\Albatross\Sql\DateTimeParser; +use PDOException; + +final class AnswerRepository +{ + private const ANSWER_FIELDS = [ + 'answer_id', + 'quiz_id', + 'answer_number', + 'submitted_at', + 'author_id', + 'code', + 'code_size', + 'execution_status', + ]; + + private const ANSWER_JOIN_USER_FIELDS = [ + 'users.username AS author_name', + 'users.is_admin AS author_is_admin', + ]; + + public function __construct( + private readonly Connection $conn, + ) { + } + + /** + * @return Answer[] + */ + public function listByQuizId(int $quiz_id): array + { + $result = $this->conn + ->query() + ->select('answers') + ->leftJoin('users', 'answers.author_id = users.user_id') + ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS]) + ->where('quiz_id = :quiz_id') + ->orderBy([['execution_status', 'DESC'], ['code_size', 'ASC'], ['submitted_at', 'ASC']]) + ->execute(['quiz_id' => $quiz_id]); + return array_map($this->mapRawRowToAnswer(...), $result); + } + + /** + * @return Answer[] + */ + public function listByQuizIdAndAuthorId(int $quiz_id, int $author_id): array + { + $result = $this->conn + ->query() + ->select('answers') + ->leftJoin('users', 'answers.author_id = users.user_id') + ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS]) + ->where('quiz_id = :quiz_id AND author_id = :author_id') + ->orderBy([['execution_status', 'DESC'], ['code_size', 'ASC'], ['submitted_at', 'ASC']]) + ->execute(['quiz_id' => $quiz_id, 'author_id' => $author_id]); + return array_map($this->mapRawRowToAnswer(...), $result); + } + + public function findByQuizIdAndAnswerNumber(int $quiz_id, int $answer_number): ?Answer + { + $result = $this->conn + ->query() + ->select('answers') + ->leftJoin('users', 'answers.author_id = users.user_id') + ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS]) + ->where('quiz_id = :quiz_id AND answer_number = :answer_number') + ->first() + ->execute(['quiz_id' => $quiz_id, 'answer_number' => $answer_number]); + return isset($result) ? $this->mapRawRowToAnswer($result) : null; + } + + public function findById(int $answer_id): ?Answer + { + $result = $this->conn + ->query() + ->select('answers') + ->leftJoin('users', 'answers.author_id = users.user_id') + ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS]) + ->where('answer_id = :answer_id') + ->first() + ->execute(['answer_id' => $answer_id]); + return isset($result) ? $this->mapRawRowToAnswer($result) : null; + } + + /** + * @param positive-int $upto + * @return Answer[] + */ + public function getRanking(int $quiz_id, int $upto): array + { + $result = $this->conn + ->query() + ->select('answers') + ->leftJoin('users', 'answers.author_id = users.user_id') + ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS]) + ->where('quiz_id = :quiz_id AND execution_status = :execution_status') + ->orderBy([['code_size', 'ASC'], ['submitted_at', 'ASC']]) + ->limit($upto) + ->execute(['quiz_id' => $quiz_id, 'execution_status' => AggregatedExecutionStatus::OK->toInt()]); + return array_map($this->mapRawRowToAnswer(...), $result); + } + + /** + * @return Answer[] + */ + public function listAllCorrectAnswers(int $quiz_id): array + { + $result = $this->conn + ->query() + ->select('answers') + ->leftJoin('users', 'answers.author_id = users.user_id') + ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS]) + ->where('quiz_id = :quiz_id AND execution_status = :execution_status') + ->orderBy([['submitted_at', 'ASC']]) + ->execute(['quiz_id' => $quiz_id, 'execution_status' => AggregatedExecutionStatus::OK->toInt()]); + return array_map($this->mapRawRowToAnswer(...), $result); + } + + public function create( + int $quiz_id, + int $author_id, + string $code, + ): int { + $answer = Answer::create( + quiz_id: $quiz_id, + author_id: $author_id, + code: $code, + ); + + $next_answer_number_query = $this->conn + ->query() + ->select('answers') + ->fields(['COALESCE(MAX(answer_number), 0) + 1']) + ->where('quiz_id = :quiz_id') + ->limit(1); + + try { + return $this->conn + ->query() + ->insert('answers') + ->values([ + 'quiz_id' => $answer->quiz_id, + 'answer_number' => $next_answer_number_query, + 'author_id' => $answer->author_id, + 'code' => $answer->code, + 'code_size' => $answer->code_size, + 'execution_status' => $answer->execution_status->toInt(), + ]) + ->execute(); + } catch (PDOException $e) { + throw new EntityValidationException( + message: '回答の投稿に失敗しました', + previous: $e, + ); + } + } + + public function markAllAsPending(int $quiz_id): void + { + $this->conn + ->query() + ->update('answers') + ->set(['execution_status' => AggregatedExecutionStatus::Pending->toInt()]) + ->where('quiz_id = :quiz_id') + ->execute(['quiz_id' => $quiz_id]); + } + + public function markAllAsUpdateNeeded(int $quiz_id): void + { + $this->conn + ->query() + ->update('answers') + ->set(['execution_status' => AggregatedExecutionStatus::UpdateNeeded->toInt()]) + ->where('quiz_id = :quiz_id') + ->execute(['quiz_id' => $quiz_id]); + } + + public function markAsPending(int $answer_id): void + { + $this->conn + ->query() + ->update('answers') + ->set(['execution_status' => AggregatedExecutionStatus::Pending->toInt()]) + ->where('answer_id = :answer_id') + ->execute(['answer_id' => $answer_id]); + } + + public function tryGetNextUpdateNeededAnswer(): ?Answer + { + $result = $this->conn + ->query() + ->select('answers') + ->leftJoin('users', 'answers.author_id = users.user_id') + ->fields([...self::ANSWER_FIELDS, ...self::ANSWER_JOIN_USER_FIELDS]) + ->where('execution_status = :execution_status') + ->orderBy([['submitted_at', 'ASC']]) + ->first() + ->execute(['execution_status' => AggregatedExecutionStatus::UpdateNeeded->toInt()]); + return isset($result) ? $this->mapRawRowToAnswer($result) : null; + } + + public function updateExecutionStatus( + int $answer_id, + AggregatedExecutionStatus $execution_status, + ): void { + $this->conn + ->query() + ->update('answers') + ->set(['execution_status' => $execution_status->toInt()]) + ->where('answer_id = :answer_id') + ->execute(['answer_id' => $answer_id]); + } + + public function deleteAllByQuizId(int $quiz_id): void + { + $this->conn + ->query() + ->delete('answers') + ->where('quiz_id = :quiz_id') + ->execute(['quiz_id' => $quiz_id]); + } + + public function deleteAllByUserId(int $user_id): void + { + $this->conn + ->query() + ->delete('answers') + ->where('author_id = :author_id') + ->execute(['author_id' => $user_id]); + } + + /** + * @param array<string, ?string> $row + */ + private function mapRawRowToAnswer(array $row): Answer + { + assert(isset($row['answer_id'])); + assert(isset($row['quiz_id'])); + assert(isset($row['answer_number'])); + assert(isset($row['submitted_at'])); + assert(isset($row['author_id'])); + assert(isset($row['code'])); + assert(isset($row['code_size'])); + assert(isset($row['execution_status'])); + + $answer_id = (int) $row['answer_id']; + $quiz_id = (int) $row['quiz_id']; + $answer_number = (int) $row['answer_number']; + $submitted_at = DateTimeParser::parse($row['submitted_at']); + assert($submitted_at instanceof DateTimeImmutable, "Failed to parse " . $row['submitted_at']); + $author_id = (int) $row['author_id']; + + return new Answer( + answer_id: $answer_id, + quiz_id: $quiz_id, + answer_number: $answer_number, + submitted_at: $submitted_at, + author_id: $author_id, + code: $row['code'], + code_size: (int) $row['code_size'], + execution_status: AggregatedExecutionStatus::fromInt((int)$row['execution_status']), + author_name: $row['author_name'] ?? null, + author_is_admin: (bool) ($row['author_is_admin'] ?? null), + ); + } +} diff --git a/services/app/src/Repositories/QuizRepository.php b/services/app/src/Repositories/QuizRepository.php new file mode 100644 index 0000000..b360f9e --- /dev/null +++ b/services/app/src/Repositories/QuizRepository.php @@ -0,0 +1,234 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Repositories; + +use DateTimeImmutable; +use DateTimeZone; +use Nsfisis\Albatross\Database\Connection; +use Nsfisis\Albatross\Exceptions\EntityValidationException; +use Nsfisis\Albatross\Models\Quiz; +use Nsfisis\Albatross\Sql\DateTimeParser; +use PDOException; + +final class QuizRepository +{ + private const QUIZ_FIELDS = [ + 'quiz_id', + 'created_at', + 'started_at', + 'ranking_hidden_at', + 'finished_at', + 'title', + 'slug', + 'description', + 'example_code', + 'birdie_code_size', + ]; + + public function __construct( + private readonly Connection $conn, + ) { + } + + /** + * @return Quiz[] + */ + public function listAll(): array + { + $result = $this->conn + ->query() + ->select('quizzes') + ->fields(self::QUIZ_FIELDS) + ->orderBy([['created_at', 'ASC']]) + ->execute(); + return array_map($this->mapRawRowToQuiz(...), $result); + } + + /** + * @return Quiz[] + */ + public function listStarted(?DateTimeImmutable $now = null): array + { + if ($now === null) { + $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); + } + $result = $this->conn + ->query() + ->select('quizzes') + ->fields(self::QUIZ_FIELDS) + ->where('started_at <= :now') + ->orderBy([['created_at', 'ASC']]) + ->execute(['now' => $now->format('Y-m-d H:i:s.u')]); + return array_map($this->mapRawRowToQuiz(...), $result); + } + + public function findById(int $quiz_id): ?Quiz + { + $result = $this->conn + ->query() + ->select('quizzes') + ->fields(self::QUIZ_FIELDS) + ->where('quiz_id = :quiz_id') + ->first() + ->execute(['quiz_id' => $quiz_id]); + return isset($result) ? $this->mapRawRowToQuiz($result) : null; + } + + public function findBySlug(string $slug): ?Quiz + { + $result = $this->conn + ->query() + ->select('quizzes') + ->fields(self::QUIZ_FIELDS) + ->where('slug = :slug') + ->first() + ->execute(['slug' => $slug]); + return isset($result) ? $this->mapRawRowToQuiz($result) : null; + } + + public function create( + string $title, + string $slug, + string $description, + string $example_code, + ?int $birdie_code_size, + DateTimeImmutable $started_at, + DateTimeImmutable $ranking_hidden_at, + DateTimeImmutable $finished_at, + ): int { + $quiz = Quiz::create( + title: $title, + slug: $slug, + description: $description, + example_code: $example_code, + birdie_code_size: $birdie_code_size, + started_at: $started_at, + ranking_hidden_at: $ranking_hidden_at, + finished_at: $finished_at, + ); + + $values = [ + 'title' => $quiz->title, + 'slug' => $quiz->slug, + 'description' => $quiz->description, + 'example_code' => $quiz->example_code, + 'started_at' => $quiz->started_at->format('Y-m-d H:i:s.u'), + 'ranking_hidden_at' => $quiz->ranking_hidden_at->format('Y-m-d H:i:s.u'), + 'finished_at' => $quiz->finished_at->format('Y-m-d H:i:s.u'), + ]; + if ($quiz->birdie_code_size !== null) { + $values['birdie_code_size'] = $quiz->birdie_code_size; + } + + try { + return $this->conn + ->query() + ->insert('quizzes') + ->values($values) + ->execute(); + } catch (PDOException $e) { + throw new EntityValidationException( + message: '問題の作成に失敗しました', + previous: $e, + ); + } + } + + public function update( + int $quiz_id, + string $title, + string $description, + string $example_code, + ?int $birdie_code_size, + DateTimeImmutable $started_at, + DateTimeImmutable $ranking_hidden_at, + DateTimeImmutable $finished_at, + ): void { + Quiz::validate( + $title, + 'dummy', + $description, + $example_code, + $birdie_code_size, + $started_at, + $ranking_hidden_at, + $finished_at, + ); + + $values = [ + 'title' => $title, + 'description' => $description, + 'example_code' => $example_code, + 'started_at' => $started_at->format('Y-m-d H:i:s.u'), + 'ranking_hidden_at' => $ranking_hidden_at->format('Y-m-d H:i:s.u'), + 'finished_at' => $finished_at->format('Y-m-d H:i:s.u'), + ]; + if ($birdie_code_size !== null) { + $values['birdie_code_size'] = $birdie_code_size; + } + + try { + $this->conn + ->query() + ->update('quizzes') + ->set($values) + ->where('quiz_id = :quiz_id') + ->execute(['quiz_id' => $quiz_id]); + } catch (PDOException $e) { + throw new EntityValidationException( + message: '問題の更新に失敗しました', + previous: $e, + ); + } + } + + public function delete(int $quiz_id): void + { + $this->conn + ->query() + ->delete('quizzes') + ->where('quiz_id = :quiz_id') + ->execute(['quiz_id' => $quiz_id]); + } + + /** + * @param array<string, string> $row + */ + private function mapRawRowToQuiz(array $row): Quiz + { + assert(isset($row['quiz_id'])); + assert(isset($row['created_at'])); + assert(isset($row['started_at'])); + assert(isset($row['ranking_hidden_at'])); + assert(isset($row['finished_at'])); + assert(isset($row['title'])); + assert(isset($row['slug'])); + assert(isset($row['description'])); + assert(isset($row['example_code'])); + + $quiz_id = (int) $row['quiz_id']; + $created_at = DateTimeParser::parse($row['created_at']); + assert($created_at instanceof DateTimeImmutable, "Failed to parse " . $row['created_at']); + $started_at = DateTimeParser::parse($row['started_at']); + assert($started_at instanceof DateTimeImmutable, "Failed to parse " . $row['started_at']); + $ranking_hidden_at = DateTimeParser::parse($row['ranking_hidden_at']); + assert($ranking_hidden_at instanceof DateTimeImmutable, "Failed to parse " . $row['ranking_hidden_at']); + $finished_at = DateTimeParser::parse($row['finished_at']); + assert($finished_at instanceof DateTimeImmutable, "Failed to parse " . $row['finished_at']); + + return new Quiz( + quiz_id: $quiz_id, + created_at: $created_at, + started_at: $started_at, + ranking_hidden_at: $ranking_hidden_at, + finished_at: $finished_at, + title: $row['title'], + slug: $row['slug'], + description: $row['description'], + example_code: $row['example_code'], + birdie_code_size: ($row['birdie_code_size'] ?? '') === '' ? null : (int) $row['birdie_code_size'], + ); + } +} diff --git a/services/app/src/Repositories/TestcaseExecutionRepository.php b/services/app/src/Repositories/TestcaseExecutionRepository.php new file mode 100644 index 0000000..dea0931 --- /dev/null +++ b/services/app/src/Repositories/TestcaseExecutionRepository.php @@ -0,0 +1,296 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Repositories; + +use Nsfisis\Albatross\Database\Connection; +use Nsfisis\Albatross\Models\ExecutionStatus; +use Nsfisis\Albatross\Models\TestcaseExecution; + +final class TestcaseExecutionRepository +{ + private const TESTCASE_EXECUTION_FIELDS = [ + 'testcase_execution_id', + 'testcase_id', + 'answer_id', + 'status', + 'stdout', + 'stderr', + ]; + + public function __construct( + private readonly Connection $conn, + ) { + } + + public function findByAnswerIdAndTestcaseExecutionId( + int $answer_id, + int $testcase_execution_id, + ): ?TestcaseExecution { + $result = $this->conn + ->query() + ->select('testcase_executions') + ->fields(self::TESTCASE_EXECUTION_FIELDS) + ->where('answer_id = :answer_id AND testcase_execution_id = :testcase_execution_id') + ->first() + ->execute([ + 'answer_id' => $answer_id, + 'testcase_execution_id' => $testcase_execution_id, + ]); + return isset($result) ? $this->mapRawRowToTestcaseExecution($result) : null; + } + + /** + * @return TestcaseExecution[] + */ + public function listByQuizId(int $quiz_id): array + { + $result = $this->conn + ->query() + ->select('testcase_executions') + ->fields(self::TESTCASE_EXECUTION_FIELDS) + ->where('quiz_id = :quiz_id') + ->orderBy([['testcase_execution_id', 'ASC']]) + ->execute(['quiz_id' => $quiz_id]); + return array_map($this->mapRawRowToTestcaseExecution(...), $result); + } + + /** + * @return TestcaseExecution[] + */ + public function listByAnswerId(int $answer_id): array + { + $result = $this->conn + ->query() + ->select('testcase_executions') + ->fields(self::TESTCASE_EXECUTION_FIELDS) + ->where('answer_id = :answer_id') + ->orderBy([['testcase_execution_id', 'ASC']]) + ->execute(['answer_id' => $answer_id]); + return array_map($this->mapRawRowToTestcaseExecution(...), $result); + } + + /** + * @return array<int, ExecutionStatus> + */ + public function getStatuses(int $answer_id): array + { + $result = $this->conn + ->query() + ->select('testcase_executions') + ->fields(['testcase_execution_id', 'status']) + ->where('answer_id = :answer_id') + ->orderBy([['testcase_execution_id', 'ASC']]) + ->execute(['answer_id' => $answer_id]); + return array_combine( + array_map(fn ($row) => (int)$row['testcase_execution_id'], $result), + array_map(fn ($row) => ExecutionStatus::fromInt((int)$row['status']), $result), + ); + } + + public function tryGetNextPendingTestcaseExecution(): ?TestcaseExecution + { + return $this->conn->transaction(function () { + $pending_ex_result = $this->conn + ->query() + ->select('testcase_executions') + ->fields(self::TESTCASE_EXECUTION_FIELDS) + ->where('status = :status') + ->orderBy([['testcase_execution_id', 'ASC']]) + ->first() + ->execute(['status' => ExecutionStatus::Pending->toInt()]); + $pending_ex = isset($pending_ex_result) ? $this->mapRawRowToTestcaseExecution($pending_ex_result) : null; + if ($pending_ex === null) { + return null; + } + $this->conn + ->query() + ->update('testcase_executions') + ->set(['status' => ExecutionStatus::Running->toInt()]) + ->where('testcase_execution_id = :testcase_execution_id') + ->execute(['testcase_execution_id' => $pending_ex->testcase_execution_id]); + return new TestcaseExecution( + testcase_execution_id: $pending_ex->testcase_execution_id, + testcase_id: $pending_ex->testcase_id, + answer_id: $pending_ex->answer_id, + status: ExecutionStatus::Running, + stdout: null, + stderr: null, + ); + }); + } + + public function create( + int $testcase_id, + int $answer_id, + ): int { + $ex = TestcaseExecution::create( + testcase_id: $testcase_id, + answer_id: $answer_id, + ); + + $values = [ + 'testcase_id' => $ex->testcase_id, + 'answer_id' => $ex->answer_id, + 'status' => $ex->status->toInt(), + ]; + + return $this->conn + ->query() + ->insert('testcase_executions') + ->values([ + 'testcase_id' => $ex->testcase_id, + 'answer_id' => $ex->answer_id, + 'status' => $ex->status->toInt(), + ]) + ->execute(); + } + + public function enqueueForAllAnswers( + int $quiz_id, + int $testcase_id, + ): void { + $this->conn + ->query() + ->insertFromSelect('testcase_executions') + ->fields(['testcase_id', 'answer_id', 'status']) + ->from($this->conn + ->query() + ->select('answers') + ->fields([':testcase_id', 'answer_id', ':status']) + ->where('quiz_id = :quiz_id')) + ->execute([ + 'quiz_id' => $quiz_id, + 'testcase_id' => $testcase_id, + 'status' => ExecutionStatus::Pending->toInt(), + ]); + } + + public function enqueueForSingleAnswer( + int $answer_id, + int $quiz_id, + ): void { + $this->conn + ->query() + ->insertFromSelect('testcase_executions') + ->fields(['testcase_id', 'answer_id', 'status']) + ->from($this->conn + ->query() + ->select('testcases') + ->fields(['testcase_id', ':answer_id', ':status']) + ->where('quiz_id = :quiz_id')) + ->execute([ + 'quiz_id' => $quiz_id, + 'answer_id' => $answer_id, + 'status' => ExecutionStatus::Pending->toInt(), + ]); + } + + public function markAllAsPendingByQuizId( + int $quiz_id, + ): void { + $this->conn + ->query() + ->update('testcase_executions') + ->set([ + 'status' => ExecutionStatus::Pending->toInt(), + 'stdout' => '', + 'stderr' => '', + ]) + ->where('answer_id IN (SELECT answer_id FROM answers WHERE quiz_id = :quiz_id)') + ->execute(['quiz_id' => $quiz_id]); + } + + public function markAllAsPendingByAnswerId( + int $answer_id, + ): void { + $this->conn + ->query() + ->update('testcase_executions') + ->set([ + 'status' => ExecutionStatus::Pending->toInt(), + 'stdout' => '', + 'stderr' => '', + ]) + ->where('answer_id = :answer_id') + ->execute(['answer_id' => $answer_id]); + } + + public function markAllAsPendingByTestcaseId( + int $testcase_id, + ): void { + $this->conn + ->query() + ->update('testcase_executions') + ->set([ + 'status' => ExecutionStatus::Pending->toInt(), + 'stdout' => '', + 'stderr' => '', + ]) + ->where('testcase_id = :testcase_id') + ->execute(['testcase_id' => $testcase_id]); + } + + public function markAsPending(int $testcase_execution_id): void + { + $this->update($testcase_execution_id, ExecutionStatus::Pending, '', ''); + } + + public function update( + int $testcase_execution_id, + ExecutionStatus $status, + ?string $stdout, + ?string $stderr, + ): void { + $values = [ + 'status' => $status->toInt(), + ]; + if ($stdout !== null) { + $values['stdout'] = $stdout; + } + if ($stderr !== null) { + $values['stderr'] = $stderr; + } + + $this->conn + ->query() + ->update('testcase_executions') + ->set($values) + ->where('testcase_execution_id = :testcase_execution_id') + ->execute(['testcase_execution_id' => $testcase_execution_id]); + } + + public function deleteByTestcaseId(int $testcase_id): void + { + $this->conn + ->query() + ->delete('testcase_executions') + ->where('testcase_id = :testcase_id') + ->execute(['testcase_id' => $testcase_id]); + } + + /** + * @param array<string, string> $row + */ + private function mapRawRowToTestcaseExecution(array $row): TestcaseExecution + { + assert(isset($row['testcase_execution_id'])); + assert(isset($row['testcase_id'])); + assert(isset($row['answer_id'])); + assert(isset($row['status'])); + + $testcase_execution_id = (int) $row['testcase_execution_id']; + $testcase_id = (int) $row['testcase_id']; + $answer_id = (int) $row['answer_id']; + + return new TestcaseExecution( + testcase_execution_id: $testcase_execution_id, + testcase_id: $testcase_id, + answer_id: $answer_id, + status: ExecutionStatus::fromInt((int)$row['status']), + stdout: $row['stdout'] ?? null, + stderr: $row['stderr'] ?? null, + ); + } +} diff --git a/services/app/src/Repositories/TestcaseRepository.php b/services/app/src/Repositories/TestcaseRepository.php new file mode 100644 index 0000000..4fd3297 --- /dev/null +++ b/services/app/src/Repositories/TestcaseRepository.php @@ -0,0 +1,143 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Repositories; + +use Nsfisis\Albatross\Database\Connection; +use Nsfisis\Albatross\Exceptions\EntityValidationException; +use Nsfisis\Albatross\Models\Testcase; +use PDOException; + +final class TestcaseRepository +{ + private const TESTCASE_FIELDS = [ + 'testcase_id', + 'quiz_id', + 'input', + 'expected_result', + ]; + + public function __construct( + private readonly Connection $conn, + ) { + } + + public function findByQuizIdAndTestcaseId( + int $quiz_id, + int $testcase_id, + ): ?Testcase { + $result = $this->conn + ->query() + ->select('testcases') + ->fields(self::TESTCASE_FIELDS) + ->where('quiz_id = :quiz_id AND testcase_id = :testcase_id') + ->first() + ->execute([ + 'quiz_id' => $quiz_id, + 'testcase_id' => $testcase_id, + ]); + return isset($result) ? $this->mapRawRowToTestcase($result) : null; + } + + /** + * @return Testcase[] + */ + public function listByQuizId(int $quiz_id): array + { + $result = $this->conn + ->query() + ->select('testcases') + ->fields(self::TESTCASE_FIELDS) + ->where('quiz_id = :quiz_id') + ->orderBy([['testcase_id', 'ASC']]) + ->execute(['quiz_id' => $quiz_id]); + return array_map($this->mapRawRowToTestcase(...), $result); + } + + public function create( + int $quiz_id, + string $input, + string $expected_result, + ): int { + $testcase = Testcase::create( + quiz_id: $quiz_id, + input: $input, + expected_result: $expected_result, + ); + + $values = [ + 'quiz_id' => $testcase->quiz_id, + 'input' => $testcase->input, + 'expected_result' => $testcase->expected_result, + ]; + + try { + return $this->conn + ->query() + ->insert('testcases') + ->values($values) + ->execute(); + } catch (PDOException $e) { + throw new EntityValidationException( + message: 'テストケースの作成に失敗しました', + previous: $e, + ); + } + } + + public function update( + int $testcase_id, + string $input, + string $expected_result, + ): void { + try { + $this->conn + ->query() + ->update('testcases') + ->set([ + 'input' => $input, + 'expected_result' => $expected_result, + ]) + ->where('testcase_id = :testcase_id') + ->execute([ + 'testcase_id' => $testcase_id, + ]); + } catch (PDOException $e) { + throw new EntityValidationException( + message: 'テストケースの更新に失敗しました', + previous: $e, + ); + } + } + + public function delete(int $testcase_id): void + { + $this->conn + ->query() + ->delete('testcases') + ->where('testcase_id = :testcase_id') + ->execute(['testcase_id' => $testcase_id]); + } + + /** + * @param array<string, string> $row + */ + private function mapRawRowToTestcase(array $row): Testcase + { + assert(isset($row['testcase_id'])); + assert(isset($row['quiz_id'])); + assert(isset($row['input'])); + assert(isset($row['expected_result'])); + + $testcase_id = (int) $row['testcase_id']; + $quiz_id = (int) $row['quiz_id']; + + return new Testcase( + testcase_id: $testcase_id, + quiz_id: $quiz_id, + input: $row['input'], + expected_result: $row['expected_result'], + ); + } +} diff --git a/services/app/src/Repositories/UserRepository.php b/services/app/src/Repositories/UserRepository.php new file mode 100644 index 0000000..adca397 --- /dev/null +++ b/services/app/src/Repositories/UserRepository.php @@ -0,0 +1,127 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Repositories; + +use Nsfisis\Albatross\Database\Connection; +use Nsfisis\Albatross\Exceptions\EntityValidationException; +use Nsfisis\Albatross\Models\User; +use PDOException; + +final class UserRepository +{ + public function __construct( + private readonly Connection $conn, + ) { + } + + /** + * @return User[] + */ + public function listAll(): array + { + $result = $this->conn + ->query() + ->select('users') + ->fields(['user_id', 'username', 'is_admin']) + ->orderBy([['user_id', 'ASC']]) + ->execute(); + return array_map($this->mapRawRowToUser(...), $result); + } + + public function findById(int $user_id): ?User + { + $result = $this->conn + ->query() + ->select('users') + ->fields(['user_id', 'username', 'is_admin']) + ->where('user_id = :user_id') + ->first() + ->execute(['user_id' => $user_id]); + return isset($result) ? $this->mapRawRowToUser($result) : null; + } + + public function findByUsername(string $username): ?User + { + $result = $this->conn + ->query() + ->select('users') + ->fields(['user_id', 'username', 'is_admin']) + ->where('username = :username') + ->first() + ->execute(['username' => $username]); + return isset($result) ? $this->mapRawRowToUser($result) : null; + } + + /** + * @return positive-int + */ + public function create( + string $username, + bool $is_admin, + ): int { + $user = User::create( + username: $username, + is_admin: $is_admin, + ); + + try { + return $this->conn + ->query() + ->insert('users') + ->values([ + 'username' => $user->username, + 'is_admin' => +$user->is_admin, + ]) + ->execute(); + } catch (PDOException $e) { + throw new EntityValidationException( + message: 'ユーザの作成に失敗しました', + previous: $e, + ); + } + } + + public function update( + int $user_id, + bool $is_admin, + ): void { + $this->conn + ->query() + ->update('users') + ->set([ + 'is_admin' => +$is_admin, + ]) + ->where('user_id = :user_id') + ->execute(['user_id' => $user_id]); + } + + public function delete(int $user_id): void + { + $this->conn + ->query() + ->delete('users') + ->where('user_id = :user_id') + ->execute(['user_id' => $user_id]); + } + + /** + * @param array<string, string> $row + */ + private function mapRawRowToUser(array $row): User + { + assert(isset($row['user_id'])); + assert(isset($row['username'])); + assert(isset($row['is_admin'])); + + $user_id = (int) $row['user_id']; + $is_admin = (bool) $row['is_admin']; + + return new User( + user_id: $user_id, + username: $row['username'], + is_admin: $is_admin, + ); + } +} diff --git a/services/app/src/SandboxExec/ExecutionResult.php b/services/app/src/SandboxExec/ExecutionResult.php new file mode 100644 index 0000000..7e8b37b --- /dev/null +++ b/services/app/src/SandboxExec/ExecutionResult.php @@ -0,0 +1,17 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\SandboxExec; + +use Nsfisis\Albatross\Models\ExecutionStatus; + +final class ExecutionResult +{ + public function __construct( + public readonly ExecutionStatus $status, + public readonly string $stdout, + public readonly string $stderr, + ) { + } +} diff --git a/services/app/src/SandboxExec/ExecutorClient.php b/services/app/src/SandboxExec/ExecutorClient.php new file mode 100644 index 0000000..783c5ae --- /dev/null +++ b/services/app/src/SandboxExec/ExecutorClient.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\SandboxExec; + +use Nsfisis\Albatross\Models\ExecutionStatus; + +final class ExecutorClient +{ + public function __construct( + private readonly string $apiEndpoint, + private readonly int $timeoutMsec, + ) { + } + + public function execute( + string $code, + string $input, + ): ExecutionResult { + $bodyJson = json_encode([ + 'code' => $code, + 'input' => $input, + 'timeout' => $this->timeoutMsec, + ]); + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'follow_location' => 0, + 'header' => [ + 'Content-type: application/json', + 'Accept: application/json', + ], + 'content' => $bodyJson, + 'timeout' => ($this->timeoutMsec + 1000) / 1000, + ], + ]); + $result = file_get_contents( + $this->apiEndpoint . '/exec', + context: $context, + ); + if ($result === false) { + return new ExecutionResult( + status: ExecutionStatus::IE, + stdout: '', + stderr: 'Failed to connect to the executor service', + ); + } + $json = json_decode($result, true); + if ($json === null) { + return new ExecutionResult( + status: ExecutionStatus::IE, + stdout: '', + stderr: 'Failed to parse the response from the executor service: invalid JSON', + ); + } + + if (!is_array($json)) { + return new ExecutionResult( + status: ExecutionStatus::IE, + stdout: '', + stderr: 'Failed to parse the response from the executor service: root object is not an array', + ); + } + if (!isset($json['status']) || !is_string($json['status'])) { + return new ExecutionResult( + status: ExecutionStatus::IE, + stdout: '', + stderr: 'Failed to parse the response from the executor service: "status" is not a string', + ); + } + if (!isset($json['stdout']) || !is_string($json['stdout'])) { + return new ExecutionResult( + status: ExecutionStatus::IE, + stdout: '', + stderr: 'Failed to parse the response from the executor service: "stdout" is not a string', + ); + } + if (!isset($json['stderr']) || !is_string($json['stderr'])) { + return new ExecutionResult( + status: ExecutionStatus::IE, + stdout: '', + stderr: 'Failed to parse the response from the executor service: "stderr" is not a string', + ); + } + + return new ExecutionResult( + status: ExecutionStatus::fromString($json['status']), + stdout: $json['stdout'], + stderr: $json['stderr'], + ); + } +} diff --git a/services/app/src/Sql/DateTimeParser.php b/services/app/src/Sql/DateTimeParser.php new file mode 100644 index 0000000..eb3b58a --- /dev/null +++ b/services/app/src/Sql/DateTimeParser.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Sql; + +use DateTimeImmutable; +use DateTimeZone; + +final class DateTimeParser +{ + private const FORMATS = [ + 'Y-m-d H:i:s.u', + 'Y-m-d H:i:s', + ]; + + public static function parse(string $s): DateTimeImmutable|false + { + foreach (self::FORMATS as $format) { + $dt = DateTimeImmutable::createFromFormat( + $format, + $s, + new DateTimeZone('UTC'), + ); + if ($dt !== false) { + return $dt; + } + } + return false; + } +} diff --git a/services/app/src/Sql/Internal/Delete.php b/services/app/src/Sql/Internal/Delete.php new file mode 100644 index 0000000..c0761a2 --- /dev/null +++ b/services/app/src/Sql/Internal/Delete.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Sql\Internal; + +use Nsfisis\Albatross\Sql\QueryBuilder; + +final class Delete +{ + private string $where = ''; + + /** + * @internal + */ + public function __construct( + private readonly QueryBuilder $sql, + private readonly string $table, + ) { + } + + public function where(string $where): self + { + $this->where = $where; + return $this; + } + + /** + * @param array<string, string|int> $params + */ + public function execute(array $params = []): void + { + $this->sql->_executeDelete($this, $params); + } + + /** + * @internal + */ + public function _getTable(): string + { + return $this->table; + } + + /** + * @internal + */ + public function _getWhere(): string + { + return $this->where; + } +} diff --git a/services/app/src/Sql/Internal/Insert.php b/services/app/src/Sql/Internal/Insert.php new file mode 100644 index 0000000..1bdd06f --- /dev/null +++ b/services/app/src/Sql/Internal/Insert.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Sql\Internal; + +use Nsfisis\Albatross\Exceptions\InvalidSqlException; +use Nsfisis\Albatross\Sql\QueryBuilder; + +final class Insert +{ + /** + * @var ?array<string, string|int|Select> $values + */ + private ?array $values; + + /** + * @internal + */ + public function __construct( + private readonly QueryBuilder $sql, + private readonly string $table, + ) { + } + + /** + * @param array<string, string|int|Select> $values + */ + public function values(array $values): self + { + $this->values = $values; + return $this; + } + + /** + * @return positive-int + */ + public function execute(): int + { + return $this->sql->_executeInsert($this); + } + + /** + * @internal + */ + public function _getTable(): string + { + return $this->table; + } + + /** + * @internal + * @return array<string, string|int|Select> + */ + public function _getValues(): array + { + if (!isset($this->values)) { + throw new InvalidSqlException('INSERT: $values must be set before calling execute()'); + } + return $this->values; + } +} diff --git a/services/app/src/Sql/Internal/InsertFromSelect.php b/services/app/src/Sql/Internal/InsertFromSelect.php new file mode 100644 index 0000000..c003aa4 --- /dev/null +++ b/services/app/src/Sql/Internal/InsertFromSelect.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Sql\Internal; + +use Nsfisis\Albatross\Exceptions\InvalidSqlException; +use Nsfisis\Albatross\Sql\QueryBuilder; + +final class InsertFromSelect +{ + /** + * @var ?list<string> + */ + private ?array $fields; + + private ?Select $from; + + /** + * @internal + */ + public function __construct( + private readonly QueryBuilder $sql, + private readonly string $table, + ) { + } + + /** + * @param list<string> $fields + */ + public function fields(array $fields): self + { + $this->fields = $fields; + return $this; + } + + public function from(Select $from): self + { + $this->from = $from; + return $this; + } + + /** + * @param array<string, string|int> $params + */ + public function execute(array $params = []): void + { + $this->sql->_executeInsertFromSelect($this, $params); + } + + /** + * @internal + */ + public function _getTable(): string + { + return $this->table; + } + + /** + * @internal + * @return list<string> + */ + public function _getFields(): array + { + if (!isset($this->fields)) { + throw new InvalidSqlException('INSERT SELECT: $fields must be set before calling execute()'); + } + return $this->fields; + } + + public function _getFrom(): Select + { + if (!isset($this->from)) { + throw new InvalidSqlException('INSERT SELECT: $from must be set before calling execute()'); + } + return $this->from; + } +} diff --git a/services/app/src/Sql/Internal/Join.php b/services/app/src/Sql/Internal/Join.php new file mode 100644 index 0000000..4c85fd8 --- /dev/null +++ b/services/app/src/Sql/Internal/Join.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Sql\Internal; + +final class Join +{ + /** + * @param 'LEFT JOIN' $type + */ + public function __construct( + public readonly string $type, + public readonly string $table, + public readonly string $on, + ) { + } +} diff --git a/services/app/src/Sql/Internal/Select.php b/services/app/src/Sql/Internal/Select.php new file mode 100644 index 0000000..cf3f77a --- /dev/null +++ b/services/app/src/Sql/Internal/Select.php @@ -0,0 +1,146 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Sql\Internal; + +use Nsfisis\Albatross\Exceptions\InvalidSqlException; +use Nsfisis\Albatross\Sql\QueryBuilder; + +/** + * @internal + */ +final class Select +{ + /** + * @var ?list<string> + */ + private ?array $fields; + + private ?Join $join = null; + + private string $where = ''; + + /** + * @var list<array{string, string}> + */ + private array $orderBy = []; + + /** + * @var ?positive-int + */ + private ?int $limit = null; + + public function __construct( + private readonly QueryBuilder $sql, + private readonly string $table, + ) { + } + + public function leftJoin(string $table, string $on): self + { + $this->join = new Join('LEFT JOIN', $table, $on); + return $this; + } + + /** + * @param list<string> $fields + */ + public function fields(array $fields): self + { + $this->fields = $fields; + return $this; + } + + public function where(string $where): self + { + $this->where = $where; + return $this; + } + + /** + * @param list<array{string, string}> $orderBy + */ + public function orderBy(array $orderBy): self + { + $this->orderBy = $orderBy; + return $this; + } + + /** + * @param positive-int $limit + */ + public function limit(int $limit): self + { + $this->limit = $limit; + return $this; + } + + public function first(): SelectFirst + { + $this->limit = 1; + return new SelectFirst($this); + } + + /** + * @param array<string, string|int> $params + * @return list<array<string, string>> + */ + public function execute(array $params = []): array + { + return $this->sql->_executeSelect($this, $params); + } + + /** + * @internal + */ + public function _getTable(): string + { + return $this->table; + } + + /** + * @internal + * @return list<string> + */ + public function _getFields(): array + { + if (!isset($this->fields)) { + throw new InvalidSqlException('SELECT: $fields must be set before calling execute()'); + } + return $this->fields; + } + + /** + * @internal + */ + public function _getJoin(): ?Join + { + return $this->join; + } + + /** + * @internal + */ + public function _getWhere(): string + { + return $this->where; + } + + /** + * @internal + * @return list<array{string, string}> + */ + public function _getOrderBy(): array + { + return $this->orderBy; + } + + /** + * @return ?positive-int + */ + public function _getLimit(): ?int + { + return $this->limit; + } +} diff --git a/services/app/src/Sql/Internal/SelectFirst.php b/services/app/src/Sql/Internal/SelectFirst.php new file mode 100644 index 0000000..baf5aae --- /dev/null +++ b/services/app/src/Sql/Internal/SelectFirst.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Sql\Internal; + +/** + * @internal + */ +final class SelectFirst +{ + public function __construct( + private readonly Select $inner, + ) { + } + + /** + * @param array<string, string|int> $params + * @return ?array<string, string> + */ + public function execute(array $params = []): ?array + { + $result = $this->inner->execute($params); + return $result[0] ?? null; + } +} diff --git a/services/app/src/Sql/Internal/Update.php b/services/app/src/Sql/Internal/Update.php new file mode 100644 index 0000000..a9e9816 --- /dev/null +++ b/services/app/src/Sql/Internal/Update.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Sql\Internal; + +use Nsfisis\Albatross\Exceptions\InvalidSqlException; +use Nsfisis\Albatross\Sql\QueryBuilder; + +final class Update +{ + /** + * @var ?array<string, string|int> + */ + private ?array $set; + + private string $where = ''; + + /** + * @internal + */ + public function __construct( + private readonly QueryBuilder $sql, + private readonly string $table, + ) { + } + + /** + * @param array<string, string|int> $set + */ + public function set(array $set): self + { + $this->set = $set; + return $this; + } + + public function where(string $where): self + { + $this->where = $where; + return $this; + } + + /** + * @param array<string, string|int> $params + */ + public function execute(array $params = []): void + { + $this->sql->_executeUpdate($this, $params); + } + + /** + * @internal + */ + public function _getTable(): string + { + return $this->table; + } + + /** + * @internal + */ + public function _getWhere(): string + { + return $this->where; + } + + /** + * @internal + * @return array<string, string|int> + */ + public function _getSet(): array + { + if (!isset($this->set)) { + throw new InvalidSqlException('UPDATE: $set must be set before calling execute()'); + } + return $this->set; + } +} diff --git a/services/app/src/Sql/QueryBuilder.php b/services/app/src/Sql/QueryBuilder.php new file mode 100644 index 0000000..3a5443b --- /dev/null +++ b/services/app/src/Sql/QueryBuilder.php @@ -0,0 +1,224 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Sql; + +use Nsfisis\Albatross\Sql\Internal\Delete; +use Nsfisis\Albatross\Sql\Internal\Insert; +use Nsfisis\Albatross\Sql\Internal\InsertFromSelect; +use Nsfisis\Albatross\Sql\Internal\Select; +use Nsfisis\Albatross\Sql\Internal\Update; +use PDO; +use PDOStatement; + +final class QueryBuilder +{ + /** + * @var array<string, PDOStatement> + */ + private array $stmtCache = []; + + /** + * @internal + */ + public function __construct( + private readonly PDO $conn, + ) { + } + + public function select(string $table): Select + { + return new Select($this, $table); + } + + public function insert(string $table): Insert + { + return new Insert($this, $table); + } + + public function insertFromSelect(string $table): InsertFromSelect + { + return new InsertFromSelect($this, $table); + } + + public function update(string $table): Update + { + return new Update($this, $table); + } + + public function delete(string $table): Delete + { + return new Delete($this, $table); + } + + public function schema(string $sql): void + { + $this->conn->exec($sql); + } + + /** + * @internal + * @param Select $select + * @param array<string, string|int> $params + * @return list<array<string, string>> + */ + public function _executeSelect(Select $select, array $params): array + { + $stmt = $this->loadCacheOrPrepare($this->compileSelect($select)); + $ok = $stmt->execute($params); + assert($ok); + /** @var list<array<string, string>> */ + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + return $rows; + } + + /** + * @param Select $select + */ + private function compileSelect(Select $select): string + { + $table = $select->_getTable(); + $join = $select->_getJoin(); + $fields = $select->_getFields(); + $where = $select->_getWhere(); + $orderBy = $select->_getOrderBy(); + $limit = $select->_getLimit(); + + return "SELECT " . + implode(', ', $fields) . + " FROM $table" . + ($join !== null ? " $join->type $join->table ON $join->on" : '') . + ($where !== '' ? " WHERE $where" : '') . + ( + 0 < count($orderBy) + ? " ORDER BY " . implode(', ', array_map(fn ($field_and_order) => "{$field_and_order[0]} {$field_and_order[1]}", $orderBy)) + : '' + ) . + ($limit !== null ? " LIMIT $limit" : ''); + } + + /** + * @internal + * @return positive-int + */ + public function _executeInsert(Insert $insert): int + { + $stmt = $this->loadCacheOrPrepare($this->compileInsert($insert)); + $ok = $stmt->execute(array_filter($insert->_getValues(), fn ($v) => !$v instanceof Select)); + assert($ok); + return $this->lastInsertId(); + } + + private function compileInsert(Insert $insert): string + { + $table = $insert->_getTable(); + $values = $insert->_getValues(); + $columns = array_keys($values); + + if (count($columns) === 0) { + return "INSERT INTO $table DEFAULT VALUES"; + } + + return "INSERT INTO $table (" . + implode(', ', $columns) . + ') VALUES (' . + implode( + ', ', + array_map( + fn ($c) => ( + $values[$c] instanceof Select + ? '(' . $this->compileSelect($values[$c]) . ')' + : ":$c" + ), + $columns, + ), + ) . + ')'; + } + + /** + * @internal + * @param array<string, string|int> $params + */ + public function _executeInsertFromSelect(InsertFromSelect $insert, array $params): void + { + $stmt = $this->loadCacheOrPrepare($this->compileInsertFromSelect($insert)); + $ok = $stmt->execute($params); + assert($ok); + } + + private function compileInsertFromSelect(InsertFromSelect $insert): string + { + $table = $insert->_getTable(); + $fields = $insert->_getFields(); + $from = $insert->_getFrom(); + + return "INSERT INTO $table (" . + implode(', ', $fields) . + ') ' . + $this->compileSelect($from); + } + + /** + * @internal + * @param array<string, string|int> $params + */ + public function _executeUpdate(Update $update, array $params): void + { + $stmt = $this->loadCacheOrPrepare($this->compileUpdate($update)); + $ok = $stmt->execute($params + $update->_getSet()); + assert($ok); + } + + private function compileUpdate(Update $update): string + { + $table = $update->_getTable(); + $set = $update->_getSet(); + $columns = array_keys($set); + $where = $update->_getWhere(); + + return "UPDATE $table SET " . + implode(', ', array_map(fn ($c) => "$c = :$c", $columns)) . + ($where !== '' ? " WHERE $where" : ''); + } + + /** + * @internal + * @param array<string, string|int> $params + */ + public function _executeDelete(Delete $delete, array $params): void + { + $stmt = $this->loadCacheOrPrepare($this->compileDelete($delete)); + $ok = $stmt->execute($params); + assert($ok); + } + + private function compileDelete(Delete $delete): string + { + $table = $delete->_getTable(); + $where = $delete->_getWhere(); + + return "DELETE FROM $table" . + ($where !== '' ? " WHERE $where" : ''); + } + + private function loadCacheOrPrepare(string $sql): PDOStatement + { + $cache = $this->stmtCache[$sql] ?? null; + if ($cache !== null) { + return $cache; + } + return $this->stmtCache[$sql] = $this->conn->prepare($sql); + } + + /** + * @return positive-int + */ + private function lastInsertId(): int + { + $inserted_id = (int) $this->conn->lastInsertId(); + assert(0 < $inserted_id); + return $inserted_id; + } +} diff --git a/services/app/src/Twig/CsrfExtension.php b/services/app/src/Twig/CsrfExtension.php new file mode 100644 index 0000000..b6369a2 --- /dev/null +++ b/services/app/src/Twig/CsrfExtension.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace Nsfisis\Albatross\Twig; + +use Slim\Csrf\Guard; +use Twig\Extension\AbstractExtension; +use Twig\Extension\GlobalsInterface; + +final class CsrfExtension extends AbstractExtension implements GlobalsInterface +{ + public function __construct(private readonly Guard $csrf) + { + } + + /** + * @return array{csrf: array{name_key: string, name: string, value_key: string, value: string}} + */ + public function getGlobals(): array + { + $csrf_name_key = $this->csrf->getTokenNameKey(); + $csrf_name = $this->csrf->getTokenName(); + assert( + isset($csrf_name), + 'It must be present here because the access is denied by Csrf\Guard middleware if absent.', + ); + + $csrf_value_key = $this->csrf->getTokenValueKey(); + $csrf_value = $this->csrf->getTokenValue(); + assert( + isset($csrf_value), + 'It must be present here because the access is denied by Csrf\Guard middleware if absent.', + ); + + return [ + 'csrf' => [ + 'name_key' => $csrf_name_key, + 'name' => $csrf_name, + 'value_key' => $csrf_value_key, + 'value' => $csrf_value + ] + ]; + } +} |
