diff options
Diffstat (limited to 'services/app/src/App.php')
| -rw-r--r-- | services/app/src/App.php | 1015 |
1 files changed, 1015 insertions, 0 deletions
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; + } +} |
