diff options
112 files changed, 10813 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6ffe7e4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,37 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space + +[{Makefile,Makefile.prod}] +indent_style = tab + +[Dockerfile] +indent_size = 4 + +[*.php] +indent_size = 4 + +[*.neon] +indent_size = 4 + +[composer.{json,lock}] +indent_size = 4 + +[*.yml] +indent_size = 4 + +[*.c] +indent_size = 4 + +[*.twig] +indent_size = 2 + +[*.js] +indent_size = 2 + +[{package,package-lock}.json] +indent_size = 2 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c5976e6 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +DOCKER_COMPOSE := docker compose -f docker-compose.local.yml --env-file .env.local + +.PHONY: up +up: + ${DOCKER_COMPOSE} up -d + +.PHONY: down +down: + ${DOCKER_COMPOSE} down + +.PHONY: build +build: build-assets + ${DOCKER_COMPOSE} build + +.PHONY: migrate +migrate: down + ${DOCKER_COMPOSE} up -d albatross-db + ${DOCKER_COMPOSE} run --rm --entrypoint="php bin/albctl migrate" albatross-jobworker + +.PHONY: promote +promote: up + ${DOCKER_COMPOSE} run --rm --entrypoint="php bin/albctl promote" albatross-jobworker + +.PHONY: deluser +deluser: up + ${DOCKER_COMPOSE} run --rm --entrypoint="php bin/albctl deluser" albatross-jobworker + +.PHONY: sh +sh: + ${DOCKER_COMPOSE} exec albatross-app sh + +.PHONY: psql +psql: + ${DOCKER_COMPOSE} exec albatross-db psql -U postgres + +.PHONY: logs +logs: + ${DOCKER_COMPOSE} logs + +.PHONY: build-assets +build-assets: services/app/public/assets + docker build -t albatross-build-assets -f services/app/Dockerfile.frontend ./services/app + docker run --rm -v "$$(pwd)"/services/app/esbuild.mjs:/app/esbuild.mjs -v "$$(pwd)"/services/app/assets:/app/assets -v "$$(pwd)"/services/app/public/assets:/app/public/assets --env-file "$$(pwd)"/.env.local albatross-build-assets npm run build + +services/app/public/assets: + @mkdir -p services/app/public/assets diff --git a/Makefile.prod b/Makefile.prod new file mode 100644 index 0000000..efb551e --- /dev/null +++ b/Makefile.prod @@ -0,0 +1,31 @@ +DOCKER_COMPOSE := docker-compose -f docker-compose.prod.yml --env-file .env.prod + +.PHONY: build +build: build-assets + ${DOCKER_COMPOSE} build + ${DOCKER_COMPOSE} up -d albatross-db + ${DOCKER_COMPOSE} run --rm --entrypoint="php bin/albctl migrate" albatross-jobworker + +.PHONY: serve +serve: + ${DOCKER_COMPOSE} up -d + +.PHONY: clean +clean: + ${DOCKER_COMPOSE} down + +.PHONY: promote +promote: + ${DOCKER_COMPOSE} run --rm --entrypoint="php bin/albctl promote" albatross-jobworker + +.PHONY: logs +logs: + ${DOCKER_COMPOSE} logs + +.PHONY: build-assets +build-assets: services/app/public/assets + docker build -t albatross-build-assets -f services/app/Dockerfile.frontend ./services/app + docker run --rm -v "$$(pwd)"/services/app/esbuild.mjs:/app/esbuild.mjs -v "$$(pwd)"/services/app/assets:/app/assets -v "$$(pwd)"/services/app/public/assets:/app/public/assets --env-file "$$(pwd)"/.env.prod albatross-build-assets npm run build + +services/app/public/assets: + @mkdir -p services/app/public/assets diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..2d46755 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,51 @@ +version: '3' + +services: + albatross-app: + build: + context: ./services/app + args: + ALBATROSS_BASE_PATH: $ALBATROSS_BASE_PATH + ports: + - '8001:80' + volumes: + - './services/app/public:/var/www/html/public' + - './services/app/src:/var/www/html/src' + - './services/app/templates:/var/www/html/templates' + - './services/app/php.local.ini:/usr/local/etc/php/php.ini' + - 'session-data:/tmp/session' + env_file: .env.local + restart: always + + albatross-jobworker: + build: + context: ./services/app + volumes: + - './services/app/bin:/var/www/html/bin' + - './services/app/src:/var/www/html/src' + - './services/app/php.local.ini:/usr/local/etc/php/php.ini' + entrypoint: 'php bin/jobworker' + env_file: .env.local + restart: always + + albatross-db: + image: postgres:16.0 + expose: + - 5432 + volumes: + - 'db-data:/var/lib/postgresql/data' + environment: + POSTGRES_PASSWORD: $ALBATROSS_DB_PASSWORD + restart: always + + albatross-sandbox-exec: + build: + context: ./services/sandbox-exec + expose: + - 8888 + env_file: .env.local + restart: always + +volumes: + db-data: + session-data: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..eff4a4c --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,51 @@ +version: '3' + +services: + albatross-app: + build: + context: ./services/app + args: + ALBATROSS_BASE_PATH: $ALBATROSS_BASE_PATH + ports: + - '8001:80' + volumes: + - './services/app/public:/var/www/html/public' + - './services/app/src:/var/www/html/src' + - './services/app/templates:/var/www/html/templates' + - './services/app/php.prod.ini:/usr/local/etc/php/conf.d/php.ini' + - 'session-data:/tmp/session' + env_file: .env.prod + restart: always + + albatross-jobworker: + build: + context: ./services/app + volumes: + - './services/app/bin:/var/www/html/bin' + - './services/app/src:/var/www/html/src' + - './services/app/php.prod.ini:/usr/local/etc/php/conf.d/php.ini' + entrypoint: 'php bin/jobworker' + env_file: .env.prod + restart: always + + albatross-db: + image: postgres:16.0 + expose: + - 5432 + volumes: + - 'db-data:/var/lib/postgresql/data' + environment: + POSTGRES_PASSWORD: $ALBATROSS_DB_PASSWORD + restart: always + + albatross-sandbox-exec: + build: + context: ./services/sandbox-exec + expose: + - 8888 + env_file: .env.prod + restart: always + +volumes: + db-data: + session-data: diff --git a/services/app/.dockerignore b/services/app/.dockerignore new file mode 100644 index 0000000..58875fd --- /dev/null +++ b/services/app/.dockerignore @@ -0,0 +1,2 @@ +/node_modules +/vendor diff --git a/services/app/.gitignore b/services/app/.gitignore new file mode 100644 index 0000000..3913c9c --- /dev/null +++ b/services/app/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/vendor +/public/assets diff --git a/services/app/Dockerfile b/services/app/Dockerfile new file mode 100644 index 0000000..25091d7 --- /dev/null +++ b/services/app/Dockerfile @@ -0,0 +1,36 @@ +FROM composer:2.6 AS composer + + +FROM php:8.2-apache + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + libpq-dev \ + unzip \ + && \ + : + +RUN a2enmod rewrite + +ARG ALBATROSS_BASE_PATH + +RUN { \ + echo "<VirtualHost *:80>"; \ + echo " Alias $ALBATROSS_BASE_PATH/ /var/www/html/public/"; \ + echo " DocumentRoot /var/www/html/public/"; \ + echo "</VirtualHost>"; \ + } > /etc/apache2/sites-available/000-default.conf + +RUN docker-php-ext-install pdo_pgsql + +COPY --from=composer /usr/bin/composer /usr/bin/ + +WORKDIR /var/www/html/ + +COPY composer.json composer.lock /var/www/html/ + +RUN composer install --no-dev --no-interaction + +RUN mkdir /var/www/html/twig-cache + +RUN mkdir /tmp/session && chown www-data:www-data /tmp/session diff --git a/services/app/Dockerfile.frontend b/services/app/Dockerfile.frontend new file mode 100644 index 0000000..e2b0d50 --- /dev/null +++ b/services/app/Dockerfile.frontend @@ -0,0 +1,6 @@ +FROM node:18.18.0-slim + +WORKDIR /app +COPY package.json package-lock.json ./ + +RUN npm install && mkdir -p /app/public/assets diff --git a/services/app/assets/chart.js b/services/app/assets/chart.js new file mode 100644 index 0000000..dab6cbf --- /dev/null +++ b/services/app/assets/chart.js @@ -0,0 +1,72 @@ +import { + Chart, + Colors, + LineController, + LineElement, + LinearScale, + PointElement, + TimeScale, + Tooltip, +} from 'chart.js' +import 'chartjs-adapter-date-fns'; + +Chart.register( + Colors, + LineController, + LineElement, + LinearScale, + PointElement, + TimeScale, + Tooltip, +); + +document.addEventListener('DOMContentLoaded', async () => { + const chartCanvas = document.getElementById('chart'); + const quizId = chartCanvas.dataset.quizId; + + const apiUrl = `${process.env.ALBATROSS_BASE_PATH}/api/quizzes/${quizId}/chart`; + const apiResult = await fetch(apiUrl).then(res => res.json()); + if (apiResult.error) { + return; + } + const stats = apiResult.stats; + + new Chart( + chartCanvas, + { + type: 'line', + data: { + datasets: stats.map(s => ({ + label: `${s.user.name}${s.user.is_admin ? ' (staff)' : ''}`, + data: s.scores.map(row => ({ x: row.submitted_at * 1000, y: row.code_size })), + })) + }, + options: { + scales: { + x: { + type: 'time', + time: { + parsing: false, + display: false, + unit: 'day', + tooltipFormat: 'yyyy-MM-dd HH:mm:ss', + displayFormats: { + day: 'yyyy-MM-dd', + }, + }, + title: { + display: true, + text: '提出日時', + }, + }, + y: { + title: { + display: true, + text: 'コードサイズ (byte)', + }, + }, + }, + }, + }, + ); +}); diff --git a/services/app/assets/index.js b/services/app/assets/index.js new file mode 100644 index 0000000..74a0682 --- /dev/null +++ b/services/app/assets/index.js @@ -0,0 +1,13 @@ +import hljs from 'highlight.js/lib/core'; +import php from 'highlight.js/lib/languages/php'; +import plaintext from 'highlight.js/lib/languages/plaintext'; +import 'highlight.js/styles/github.css'; +import 'bootstrap/dist/css/bootstrap.css'; + +document.addEventListener('DOMContentLoaded', () => { + hljs.registerLanguage('php', php); + hljs.registerLanguage('plaintext', plaintext); + hljs.highlightAll(); +}); + +console.log(`#Albatross!`); diff --git a/services/app/assets/loading.js b/services/app/assets/loading.js new file mode 100644 index 0000000..570a7e9 --- /dev/null +++ b/services/app/assets/loading.js @@ -0,0 +1,54 @@ +document.addEventListener('DOMContentLoaded', () => { + const aggregatedStatusElem = document.getElementsByClassName('js-aggregated-execution-status')[0]; + const aggregatedStatusLoadingIndicatorElem = document.getElementsByClassName('js-aggregated-execution-status-loading-indicator')[0]; + const answerId = aggregatedStatusElem.dataset.answerId; + + const getElemsMap = cls => new Map( + Array.from(document.getElementsByClassName(cls) ?? []) + .map(e => [parseInt(e.dataset.testcaseExecutionId), e]) + ); + const statusElemsMap = getElemsMap('js-testcase-execution-status'); + const statusLoadingIndicatorElemsMap = getElemsMap('js-testcase-execution-status-loading-indicator'); + const stdoutElemsMap = getElemsMap('js-testcase-execution-stdout'); + const stderrElemsMap = getElemsMap('js-testcase-execution-stderr'); + + if (!aggregatedStatusLoadingIndicatorElem) { + return; + } + + const apiUrl = `${process.env.ALBATROSS_BASE_PATH}/api/answers/${answerId}/statuses`; + + let timerId; + timerId = setInterval(() => { + fetch(apiUrl) + .then(response => response.json()) + .then(({ aggregated_status, testcase_executions }) => { + for (const ex of testcase_executions) { + const statusElem = statusElemsMap.get(ex.id); + const loadingIndicatorElem = statusLoadingIndicatorElemsMap.get(ex.id); + const stdoutElem = stdoutElemsMap.get(ex.id); + const stderrElem = stderrElemsMap.get(ex.id); + + const { status, stdout, stderr } = ex; + if (status.label === statusElem.textContent) { + continue; + } + statusElem.textContent = status.label; + stdoutElem.textContent = stdout; + stderrElem.textContent = stderr; + if (loadingIndicatorElem && !status.show_loading_indicator) { + loadingIndicatorElem.remove(); + } + } + + if (aggregated_status.label === aggregatedStatusElem.textContent) { + return; + } + aggregatedStatusElem.textContent = aggregated_status.label; + if (!aggregated_status.show_loading_indicator) { + aggregatedStatusLoadingIndicatorElem.remove(); + clearInterval(timerId); + } + }); + }, 5 * 1000); +}); diff --git a/services/app/bin/albctl b/services/app/bin/albctl new file mode 100644 index 0000000..462e62c --- /dev/null +++ b/services/app/bin/albctl @@ -0,0 +1,11 @@ +#!/usr/bin/env php +<?php + +declare(strict_types=1); + +require __DIR__ . '/../vendor/autoload.php'; + +use Nsfisis\Albatross\Config; +use Nsfisis\Albatross\AlbCtl; + +(new AlbCtl(Config::fromEnvVars()))->run($argv); diff --git a/services/app/bin/jobworker b/services/app/bin/jobworker new file mode 100644 index 0000000..76cc1e6 --- /dev/null +++ b/services/app/bin/jobworker @@ -0,0 +1,11 @@ +#!/usr/bin/env php +<?php + +declare(strict_types=1); + +require __DIR__ . '/../vendor/autoload.php'; + +use Nsfisis\Albatross\Config; +use Nsfisis\Albatross\JobWorker; + +(new JobWorker(Config::fromEnvVars()))->run(); diff --git a/services/app/composer.json b/services/app/composer.json new file mode 100644 index 0000000..e87d1f1 --- /dev/null +++ b/services/app/composer.json @@ -0,0 +1,34 @@ +{ + "name": "nsfisis/albatross", + "license": "MIT License", + "autoload": { + "psr-4": { + "Nsfisis\\Albatross\\": "src/" + } + }, + "require": { + "middlewares/php-session": "^3.1", + "php-di/slim-bridge": "^3.4", + "slim/csrf": "^1.3", + "slim/psr7": "^1.6", + "slim/slim": "^4.12", + "slim/twig-view": "^3.3" + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-deprecation-rules": "^1.1", + "phpstan/phpstan-strict-rules": "^1.5", + "phpunit/phpunit": "^10.3", + "symplify/easy-coding-standard": "^12.0" + }, + "config": { + "preferred-install": "dist", + "sort-packages": true + }, + "scripts": { + "ecs": "ecs check", + "ecsfix": "ecs check --fix", + "phpstan": "phpstan analyse", + "phpunit": "phpunit tests" + } +} diff --git a/services/app/composer.lock b/services/app/composer.lock new file mode 100644 index 0000000..5fdf8ab --- /dev/null +++ b/services/app/composer.lock @@ -0,0 +1,3324 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "7a5dbfac135af5d38f0b0674d991751b", + "packages": [ + { + "name": "fig/http-message-util", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message-util.git", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message-util/zipball/9d94dc0154230ac39e5bf89398b324a86f63f765", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "suggest": { + "psr/http-message": "The package containing the PSR-7 interfaces" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Fig\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Utility classes and constants for use with PSR-7 (psr/http-message)", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-message-util/issues", + "source": "https://github.com/php-fig/http-message-util/tree/1.1.5" + }, + "time": "2020-11-24T22:02:12+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v1.3.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "e5a3057a5591e1cfe8183034b0203921abe2c902" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/e5a3057a5591e1cfe8183034b0203921abe2c902", + "reference": "e5a3057a5591e1cfe8183034b0203921abe2c902", + "shasum": "" + }, + "require": { + "php": "^7.3|^8.0" + }, + "require-dev": { + "nesbot/carbon": "^2.61", + "pestphp/pest": "^1.21.3", + "phpstan/phpstan": "^1.8.2", + "symfony/var-dumper": "^5.4.11" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2023-07-14T13:56:28+00:00" + }, + { + "name": "middlewares/php-session", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/middlewares/php-session.git", + "reference": "089ea6929435d6f6f26e61a4f8ab682544294c31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/middlewares/php-session/zipball/089ea6929435d6f6f26e61a4f8ab682544294c31", + "reference": "089ea6929435d6f6f26e61a4f8ab682544294c31", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.0", + "laminas/laminas-diactoros": "^2.3", + "middlewares/utils": "^3.0", + "oscarotero/php-cs-fixer-config": "^1.0", + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^8|^9", + "squizlabs/php_codesniffer": "^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Middlewares\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Middleware to start php sessions using the request data", + "homepage": "https://github.com/middlewares/php-session", + "keywords": [ + "http", + "middleware", + "psr-15", + "psr-7", + "server", + "session" + ], + "support": { + "issues": "https://github.com/middlewares/php-session/issues", + "source": "https://github.com/middlewares/php-session/tree/v3.1.1" + }, + "time": "2022-03-13T01:22:27+00:00" + }, + { + "name": "nikic/fast-route", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/FastRoute.git", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|~5.7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "FastRoute\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov", + "email": "nikic@php.net" + } + ], + "description": "Fast request router for PHP", + "keywords": [ + "router", + "routing" + ], + "support": { + "issues": "https://github.com/nikic/FastRoute/issues", + "source": "https://github.com/nikic/FastRoute/tree/master" + }, + "time": "2018-02-13T20:26:39+00:00" + }, + { + "name": "php-di/invoker", + "version": "2.3.4", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/Invoker.git", + "reference": "33234b32dafa8eb69202f950a1fc92055ed76a86" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/33234b32dafa8eb69202f950a1fc92055ed76a86", + "reference": "33234b32dafa8eb69202f950a1fc92055ed76a86", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "psr/container": "^1.0|^2.0" + }, + "require-dev": { + "athletic/athletic": "~0.1.8", + "mnapoli/hard-mode": "~0.3.0", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Invoker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Generic and extensible callable invoker", + "homepage": "https://github.com/PHP-DI/Invoker", + "keywords": [ + "callable", + "dependency", + "dependency-injection", + "injection", + "invoke", + "invoker" + ], + "support": { + "issues": "https://github.com/PHP-DI/Invoker/issues", + "source": "https://github.com/PHP-DI/Invoker/tree/2.3.4" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2023-09-08T09:24:21+00:00" + }, + { + "name": "php-di/php-di", + "version": "7.0.5", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/PHP-DI.git", + "reference": "9ea40a5a6970bf1ca5cbe148bc16cbad6ca3db6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/9ea40a5a6970bf1ca5cbe148bc16cbad6ca3db6c", + "reference": "9ea40a5a6970bf1ca5cbe148bc16cbad6ca3db6c", + "shasum": "" + }, + "require": { + "laravel/serializable-closure": "^1.0", + "php": ">=8.0", + "php-di/invoker": "^2.0", + "psr/container": "^1.1 || ^2.0" + }, + "provide": { + "psr/container-implementation": "^1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "friendsofphp/proxy-manager-lts": "^1", + "mnapoli/phpunit-easymock": "^1.3", + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "^4.6" + }, + "suggest": { + "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "DI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The dependency injection container for humans", + "homepage": "https://php-di.org/", + "keywords": [ + "PSR-11", + "container", + "container-interop", + "dependency injection", + "di", + "ioc", + "psr11" + ], + "support": { + "issues": "https://github.com/PHP-DI/PHP-DI/issues", + "source": "https://github.com/PHP-DI/PHP-DI/tree/7.0.5" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/php-di/php-di", + "type": "tidelift" + } + ], + "time": "2023-08-10T14:57:56+00:00" + }, + { + "name": "php-di/slim-bridge", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/Slim-Bridge.git", + "reference": "d14c95b34b3c5ba2e8c40020dd93fdcc8f3ba875" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/Slim-Bridge/zipball/d14c95b34b3c5ba2e8c40020dd93fdcc8f3ba875", + "reference": "d14c95b34b3c5ba2e8c40020dd93fdcc8f3ba875", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-di/invoker": "^2.0.0", + "php-di/php-di": "^6.0|^7.0", + "slim/slim": "^4.2.0" + }, + "require-dev": { + "laminas/laminas-diactoros": "^2.1", + "phpunit/phpunit": ">= 7.0 < 10" + }, + "type": "library", + "autoload": { + "psr-4": { + "DI\\Bridge\\Slim\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP-DI integration in Slim", + "support": { + "issues": "https://github.com/PHP-DI/Slim-Bridge/issues", + "source": "https://github.com/PHP-DI/Slim-Bridge/tree/3.4.0" + }, + "time": "2023-06-29T14:08:47+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "e616d01114759c4c489f93b099585439f795fe35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35", + "reference": "e616d01114759c4c489f93b099585439f795fe35", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory/tree/1.0.2" + }, + "time": "2023-04-10T20:10:41+00:00" + }, + { + "name": "psr/http-message", + "version": "1.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/1.1" + }, + "time": "2023-04-04T09:50:52+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.0" + }, + "time": "2021-07-14T16:46:02+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "slim/csrf", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/slimphp/Slim-Csrf.git", + "reference": "ebaaf295fd6d7224078d8ae3bba45329b31798c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slimphp/Slim-Csrf/zipball/ebaaf295fd6d7224078d8ae3bba45329b31798c7", + "reference": "ebaaf295fd6d7224078d8ae3bba45329b31798c7", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "phpspec/prophecy": "^1.15", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Slim\\Csrf\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Josh Lockhart", + "email": "hello@joshlockhart.com", + "homepage": "http://joshlockhart.com" + } + ], + "description": "Slim Framework 4 CSRF protection PSR-15 middleware", + "homepage": "https://www.slimframework.com", + "keywords": [ + "csrf", + "framework", + "middleware", + "slim" + ], + "support": { + "issues": "https://github.com/slimphp/Slim-Csrf/issues", + "source": "https://github.com/slimphp/Slim-Csrf/tree/1.3.0" + }, + "time": "2022-11-05T19:27:53+00:00" + }, + { + "name": "slim/psr7", + "version": "1.6.1", + "source": { + "type": "git", + "url": "https://github.com/slimphp/Slim-Psr7.git", + "reference": "72d2b2bac94ab4575d369f605dbfafbe168d3163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slimphp/Slim-Psr7/zipball/72d2b2bac94ab4575d369f605dbfafbe168d3163", + "reference": "72d2b2bac94ab4575d369f605dbfafbe168d3163", + "shasum": "" + }, + "require": { + "fig/http-message-util": "^1.1.5", + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0", + "ralouphie/getallheaders": "^3.0", + "symfony/polyfill-php80": "^1.26" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "adriansuter/php-autoload-override": "^1.3", + "ext-json": "*", + "http-interop/http-factory-tests": "^0.9.0", + "php-http/psr7-integration-tests": "1.1", + "phpspec/prophecy": "^1.15", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Slim\\Psr7\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Josh Lockhart", + "email": "hello@joshlockhart.com", + "homepage": "http://joshlockhart.com" + }, + { + "name": "Andrew Smith", + "email": "a.smith@silentworks.co.uk", + "homepage": "http://silentworks.co.uk" + }, + { + "name": "Rob Allen", + "email": "rob@akrabat.com", + "homepage": "http://akrabat.com" + }, + { + "name": "Pierre Berube", + "email": "pierre@lgse.com", + "homepage": "http://www.lgse.com" + } + ], + "description": "Strict PSR-7 implementation", + "homepage": "https://www.slimframework.com", + "keywords": [ + "http", + "psr-7", + "psr7" + ], + "support": { + "issues": "https://github.com/slimphp/Slim-Psr7/issues", + "source": "https://github.com/slimphp/Slim-Psr7/tree/1.6.1" + }, + "time": "2023-04-17T16:02:20+00:00" + }, + { + "name": "slim/slim", + "version": "4.12.0", + "source": { + "type": "git", + "url": "https://github.com/slimphp/Slim.git", + "reference": "e9e99c2b24398b967841c6c4c3048622cc7e2b18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slimphp/Slim/zipball/e9e99c2b24398b967841c6c4c3048622cc7e2b18", + "reference": "e9e99c2b24398b967841c6c4c3048622cc7e2b18", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nikic/fast-route": "^1.3", + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "adriansuter/php-autoload-override": "^1.4", + "ext-simplexml": "*", + "guzzlehttp/psr7": "^2.5", + "httpsoft/http-message": "^1.1", + "httpsoft/http-server-request": "^1.1", + "laminas/laminas-diactoros": "^2.17", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.0", + "phpspec/prophecy": "^1.17", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.6", + "slim/http": "^1.3", + "slim/psr7": "^1.6", + "squizlabs/php_codesniffer": "^3.7" + }, + "suggest": { + "ext-simplexml": "Needed to support XML format in BodyParsingMiddleware", + "ext-xml": "Needed to support XML format in BodyParsingMiddleware", + "php-di/php-di": "PHP-DI is the recommended container library to be used with Slim", + "slim/psr7": "Slim PSR-7 implementation. See https://www.slimframework.com/docs/v4/start/installation.html for more information." + }, + "type": "library", + "autoload": { + "psr-4": { + "Slim\\": "Slim" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Josh Lockhart", + "email": "hello@joshlockhart.com", + "homepage": "https://joshlockhart.com" + }, + { + "name": "Andrew Smith", + "email": "a.smith@silentworks.co.uk", + "homepage": "http://silentworks.co.uk" + }, + { + "name": "Rob Allen", + "email": "rob@akrabat.com", + "homepage": "http://akrabat.com" + }, + { + "name": "Pierre Berube", + "email": "pierre@lgse.com", + "homepage": "http://www.lgse.com" + }, + { + "name": "Gabriel Manricks", + "email": "gmanricks@me.com", + "homepage": "http://gabrielmanricks.com" + } + ], + "description": "Slim is a PHP micro framework that helps you quickly write simple yet powerful web applications and APIs", + "homepage": "https://www.slimframework.com", + "keywords": [ + "api", + "framework", + "micro", + "router" + ], + "support": { + "docs": "https://www.slimframework.com/docs/v4/", + "forum": "https://discourse.slimframework.com/", + "irc": "irc://irc.freenode.net:6667/slimphp", + "issues": "https://github.com/slimphp/Slim/issues", + "rss": "https://www.slimframework.com/blog/feed.rss", + "slack": "https://slimphp.slack.com/", + "source": "https://github.com/slimphp/Slim", + "wiki": "https://github.com/slimphp/Slim/wiki" + }, + "funding": [ + { + "url": "https://opencollective.com/slimphp", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/slim/slim", + "type": "tidelift" + } + ], + "time": "2023-07-23T04:54:29+00:00" + }, + { + "name": "slim/twig-view", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/slimphp/Twig-View.git", + "reference": "df6dd6af6bbe28041be49c9fb8470c2e9b70cd98" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slimphp/Twig-View/zipball/df6dd6af6bbe28041be49c9fb8470c2e9b70cd98", + "reference": "df6dd6af6bbe28041be49c9fb8470c2e9b70cd98", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/http-message": "^1.0", + "slim/slim": "^4.9", + "symfony/polyfill-php81": "^1.23", + "twig/twig": "^3.3" + }, + "require-dev": { + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/phpstan": "^1.3.0", + "phpunit/phpunit": "^9.5", + "psr/http-factory": "^1.0", + "squizlabs/php_codesniffer": "^3.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Slim\\Views\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Josh Lockhart", + "email": "hello@joshlockhart.com", + "homepage": "http://joshlockhart.com" + }, + { + "name": "Pierre Berube", + "email": "pierre@lgse.com", + "homepage": "http://www.lgse.com" + } + ], + "description": "Slim Framework 4 view helper built on top of the Twig 3 templating component", + "homepage": "https://www.slimframework.com", + "keywords": [ + "framework", + "slim", + "template", + "twig", + "view" + ], + "support": { + "issues": "https://github.com/slimphp/Twig-View/issues", + "source": "https://github.com/slimphp/Twig-View/tree/3.3.0" + }, + "time": "2022-01-02T05:14:45+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.28.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", + "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.28-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-01-26T09:26:14+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.28.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "42292d99c55abe617799667f454222c54c60e229" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229", + "reference": "42292d99c55abe617799667f454222c54c60e229", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.28-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-07-28T09:04:16+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.28.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5", + "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.28-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.28.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-01-26T09:26:14+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.28.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "7581cd600fa9fd681b797d00b02f068e2f13263b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/7581cd600fa9fd681b797d00b02f068e2f13263b", + "reference": "7581cd600fa9fd681b797d00b02f068e2f13263b", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.28-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.28.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-01-26T09:26:14+00:00" + }, + { + "name": "twig/twig", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "a0ce373a0ca3bf6c64b9e3e2124aca502ba39554" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a0ce373a0ca3bf6c64b9e3e2124aca502ba39554", + "reference": "a0ce373a0ca3bf6c64b9e3e2124aca502ba39554", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2023-08-28T11:09:02+00:00" + } + ], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.11.1", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3,<3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2023-03-08T13:26:56+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v4.17.1", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": ">=7.0" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v4.17.1" + }, + "time": "2023-08-13T19:53:39+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", + "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.3" + }, + "time": "2021-07-20T11:28:43+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "1.10.35", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "e730e5facb75ffe09dfb229795e8c01a459f26c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e730e5facb75ffe09dfb229795e8c01a459f26c3", + "reference": "e730e5facb75ffe09dfb229795e8c01a459f26c3", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan", + "type": "tidelift" + } + ], + "time": "2023-09-19T15:27:56+00:00" + }, + { + "name": "phpstan/phpstan-deprecation-rules", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", + "reference": "089d8a8258ed0aeefdc7b68b6c3d25572ebfdbaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/089d8a8258ed0aeefdc7b68b6c3d25572ebfdbaa", + "reference": "089d8a8258ed0aeefdc7b68b6c3d25572ebfdbaa", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.10.3" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-php-parser": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^9.5" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.", + "support": { + "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", + "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/1.1.4" + }, + "time": "2023-08-05T09:02:04+00:00" + }, + { + "name": "phpstan/phpstan-strict-rules", + "version": "1.5.1", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-strict-rules.git", + "reference": "b21c03d4f6f3a446e4311155f4be9d65048218e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/b21c03d4f6f3a446e4311155f4be9d65048218e6", + "reference": "b21c03d4f6f3a446e4311155f4be9d65048218e6", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.10" + }, + "require-dev": { + "nikic/php-parser": "^4.13.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^9.5" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Extra strict and opinionated rules for PHPStan", + "support": { + "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/1.5.1" + }, + "time": "2023-03-29T14:47:40+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "56f33548fe522c8d82da7ff3824b42829d324364" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/56f33548fe522c8d82da7ff3824b42829d324364", + "reference": "56f33548fe522c8d82da7ff3824b42829d324364", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.15", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.0", + "phpunit/php-text-template": "^3.0", + "sebastian/code-unit-reverse-lookup": "^3.0", + "sebastian/complexity": "^3.0", + "sebastian/environment": "^6.0", + "sebastian/lines-of-code": "^2.0", + "sebastian/version": "^4.0", + "theseer/tokenizer": "^1.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-09-19T04:59:03+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.3.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "747c3b2038f1139e3dcd9886a3f5a948648b7503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/747c3b2038f1139e3dcd9886a3f5a948648b7503", + "reference": "747c3b2038f1139e3dcd9886a3f5a948648b7503", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.10.1", + "phar-io/manifest": "^2.0.3", + "phar-io/version": "^3.0.2", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.5", + "phpunit/php-file-iterator": "^4.0", + "phpunit/php-invoker": "^4.0", + "phpunit/php-text-template": "^3.0", + "phpunit/php-timer": "^6.0", + "sebastian/cli-parser": "^2.0", + "sebastian/code-unit": "^2.0", + "sebastian/comparator": "^5.0", + "sebastian/diff": "^5.0", + "sebastian/environment": "^6.0", + "sebastian/exporter": "^5.1", + "sebastian/global-state": "^6.0.1", + "sebastian/object-enumerator": "^5.0", + "sebastian/recursion-context": "^5.0", + "sebastian/type": "^4.0", + "sebastian/version": "^4.0" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.3-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.3.5" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2023-09-19T05:42:37+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "efdc130dbbbb8ef0b545a994fd811725c5282cae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/efdc130dbbbb8ef0b545a994fd811725c5282cae", + "reference": "efdc130dbbbb8ef0b545a994fd811725c5282cae", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:15+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2db5010a484d53ebf536087a70b4a5423c102372" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2db5010a484d53ebf536087a70b4a5423c102372", + "reference": "2db5010a484d53ebf536087a70b4a5423c102372", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-14T13:18:12+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68cfb347a44871f01e33ab0ef8215966432f6957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68cfb347a44871f01e33ab0ef8215966432f6957", + "reference": "68cfb347a44871f01e33ab0ef8215966432f6957", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.10", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-09-28T11:50:59+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "912dc2fbe3e3c1e7873313cc801b100b6c68c87b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/912dc2fbe3e3c1e7873313cc801b100b6c68c87b", + "reference": "912dc2fbe3e3c1e7873313cc801b100b6c68c87b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-05-01T07:48:21+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "43c751b41d74f96cbbd4e07b7aec9675651e2951" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/43c751b41d74f96cbbd4e07b7aec9675651e2951", + "reference": "43c751b41d74f96cbbd4e07b7aec9675651e2951", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-04-11T05:39:26+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "64f51654862e0f5e318db7e9dcc2292c63cdbddc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/64f51654862e0f5e318db7e9dcc2292c63cdbddc", + "reference": "64f51654862e0f5e318db7e9dcc2292c63cdbddc", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-09-24T13:22:09+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "7ea9ead78f6d380d2a667864c132c2f7b83055e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/7ea9ead78f6d380d2a667864c132c2f7b83055e4", + "reference": "7ea9ead78f6d380d2a667864c132c2f7b83055e4", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-07-19T07:19:23+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "649e40d279e243d985aa8fb6e74dd5bb28dc185d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/649e40d279e243d985aa8fb6e74dd5bb28dc185d", + "reference": "649e40d279e243d985aa8fb6e74dd5bb28dc185d", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.10", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T09:25:50+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "05909fb5bc7df4c52992396d0116aed689f93712" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/05909fb5bc7df4c52992396d0116aed689f93712", + "reference": "05909fb5bc7df4c52992396d0116aed689f93712", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:05:40+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "symplify/easy-coding-standard", + "version": "12.0.8", + "source": { + "type": "git", + "url": "https://github.com/easy-coding-standard/easy-coding-standard.git", + "reference": "99d87d188acc712dd6655ee946569f823cfeff69" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/easy-coding-standard/easy-coding-standard/zipball/99d87d188acc712dd6655ee946569f823cfeff69", + "reference": "99d87d188acc712dd6655ee946569f823cfeff69", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "conflict": { + "friendsofphp/php-cs-fixer": "<3.0", + "squizlabs/php_codesniffer": "<3.6", + "symplify/coding-standard": "<11.3" + }, + "bin": [ + "bin/ecs" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Use Coding Standard with 0-knowledge of PHP-CS-Fixer and PHP_CodeSniffer", + "keywords": [ + "Code style", + "automation", + "fixer", + "static analysis" + ], + "support": { + "issues": "https://github.com/easy-coding-standard/easy-coding-standard/issues", + "source": "https://github.com/easy-coding-standard/easy-coding-standard/tree/12.0.8" + }, + "funding": [ + { + "url": "https://www.paypal.me/rectorphp", + "type": "custom" + }, + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2023-09-08T10:17:14+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e", + "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.2.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2021-07-28T10:34:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/services/app/ecs.php b/services/app/ecs.php new file mode 100644 index 0000000..55def01 --- /dev/null +++ b/services/app/ecs.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +use Symplify\EasyCodingStandard\Config\ECSConfig; +use Symplify\EasyCodingStandard\ValueObject\Set\SetList; + +return function (ECSConfig $ecsConfig): void { + $ecsConfig->paths([ + __DIR__ . '/bin', + __DIR__ . '/src', + __DIR__ . '/tests', + ]); + $ecsConfig->sets([ + SetList::CLEAN_CODE, + SetList::PSR_12, + ]); +}; diff --git a/services/app/esbuild.mjs b/services/app/esbuild.mjs new file mode 100644 index 0000000..0e93539 --- /dev/null +++ b/services/app/esbuild.mjs @@ -0,0 +1,22 @@ +import { build } from 'esbuild' + +let ALBATROSS_BASE_PATH = process.env.ALBATROSS_BASE_PATH; +if (ALBATROSS_BASE_PATH == null) { + throw new Error('$ALBATROSS_BASE_PATH is not set'); +} +// WORKAROUND: +// Unquote the value as work-around for Docker and Docker Compose. +// How Docker parses .env files is different from how Docker Compose does for some reason. +// Docker treats the value as is, while Docker Compose strips the outermost quotes. +ALBATROSS_BASE_PATH = ALBATROSS_BASE_PATH.replace(/^"(.*)"$/, '$1'); + +await build({ + entryPoints: ['assets/index.js', 'assets/chart.js', 'assets/loading.js'], + outdir: 'public/assets', + bundle: true, + minify: true, + sourcemap: true, + define: { + 'process.env.ALBATROSS_BASE_PATH': JSON.stringify(ALBATROSS_BASE_PATH), + }, +}); diff --git a/services/app/package-lock.json b/services/app/package-lock.json new file mode 100644 index 0000000..f2cb694 --- /dev/null +++ b/services/app/package-lock.json @@ -0,0 +1,482 @@ +{ + "name": "albatross", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "albatross", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "bootstrap": "5.3.2", + "chart.js": "^4.4.1", + "chartjs-adapter-date-fns": "^3.0.0", + "date-fns": "^3.1.0", + "highlight.js": "11.8.0" + }, + "devDependencies": { + "esbuild": "0.19.4" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.4.tgz", + "integrity": "sha512-uBIbiYMeSsy2U0XQoOGVVcpIktjLMEKa7ryz2RLr7L/vTnANNEsPVAh4xOv7ondGz6ac1zVb0F8Jx20rQikffQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.4.tgz", + "integrity": "sha512-mRsi2vJsk4Bx/AFsNBqOH2fqedxn5L/moT58xgg51DjX1la64Z3Npicut2VbhvDFO26qjWtPMsVxCd80YTFVeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.4.tgz", + "integrity": "sha512-4iPufZ1TMOD3oBlGFqHXBpa3KFT46aLl6Vy7gwed0ZSYgHaZ/mihbYb4t7Z9etjkC9Al3ZYIoOaHrU60gcMy7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.4.tgz", + "integrity": "sha512-Lviw8EzxsVQKpbS+rSt6/6zjn9ashUZ7Tbuvc2YENgRl0yZTktGlachZ9KMJUsVjZEGFVu336kl5lBgDN6PmpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.4.tgz", + "integrity": "sha512-YHbSFlLgDwglFn0lAO3Zsdrife9jcQXQhgRp77YiTDja23FrC2uwnhXMNkAucthsf+Psr7sTwYEryxz6FPAVqw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.4.tgz", + "integrity": "sha512-vz59ijyrTG22Hshaj620e5yhs2dU1WJy723ofc+KUgxVCM6zxQESmWdMuVmUzxtGqtj5heHyB44PjV/HKsEmuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.4.tgz", + "integrity": "sha512-3sRbQ6W5kAiVQRBWREGJNd1YE7OgzS0AmOGjDmX/qZZecq8NFlQsQH0IfXjjmD0XtUYqr64e0EKNFjMUlPL3Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.4.tgz", + "integrity": "sha512-z/4ArqOo9EImzTi4b6Vq+pthLnepFzJ92BnofU1jgNlcVb+UqynVFdoXMCFreTK7FdhqAzH0vmdwW5373Hm9pg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.4.tgz", + "integrity": "sha512-ZWmWORaPbsPwmyu7eIEATFlaqm0QGt+joRE9sKcnVUG3oBbr/KYdNE2TnkzdQwX6EDRdg/x8Q4EZQTXoClUqqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.4.tgz", + "integrity": "sha512-EGc4vYM7i1GRUIMqRZNCTzJh25MHePYsnQfKDexD8uPTCm9mK56NIL04LUfX2aaJ+C9vyEp2fJ7jbqFEYgO9lQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.4.tgz", + "integrity": "sha512-WVhIKO26kmm8lPmNrUikxSpXcgd6HDog0cx12BUfA2PkmURHSgx9G6vA19lrlQOMw+UjMZ+l3PpbtzffCxFDRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.4.tgz", + "integrity": "sha512-keYY+Hlj5w86hNp5JJPuZNbvW4jql7c1eXdBUHIJGTeN/+0QFutU3GrS+c27L+NTmzi73yhtojHk+lr2+502Mw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.4.tgz", + "integrity": "sha512-tQ92n0WMXyEsCH4m32S21fND8VxNiVazUbU4IUGVXQpWiaAxOBvtOtbEt3cXIV3GEBydYsY8pyeRMJx9kn3rvw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.4.tgz", + "integrity": "sha512-tRRBey6fG9tqGH6V75xH3lFPpj9E8BH+N+zjSUCnFOX93kEzqS0WdyJHkta/mmJHn7MBaa++9P4ARiU4ykjhig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.4.tgz", + "integrity": "sha512-152aLpQqKZYhThiJ+uAM4PcuLCAOxDsCekIbnGzPKVBRUDlgaaAfaUl5NYkB1hgY6WN4sPkejxKlANgVcGl9Qg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.4.tgz", + "integrity": "sha512-Mi4aNA3rz1BNFtB7aGadMD0MavmzuuXNTaYL6/uiYIs08U7YMPETpgNn5oue3ICr+inKwItOwSsJDYkrE9ekVg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.4.tgz", + "integrity": "sha512-9+Wxx1i5N/CYo505CTT7T+ix4lVzEdz0uCoYGxM5JDVlP2YdDC1Bdz+Khv6IbqmisT0Si928eAxbmGkcbiuM/A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.4.tgz", + "integrity": "sha512-MFsHleM5/rWRW9EivFssop+OulYVUoVcqkyOkjiynKBCGBj9Lihl7kh9IzrreDyXa4sNkquei5/DTP4uCk25xw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.4.tgz", + "integrity": "sha512-6Xq8SpK46yLvrGxjp6HftkDwPP49puU4OF0hEL4dTxqCbfx09LyrbUj/D7tmIRMj5D5FCUPksBbxyQhp8tmHzw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.4.tgz", + "integrity": "sha512-PkIl7Jq4mP6ke7QKwyg4fD4Xvn8PXisagV/+HntWoDEdmerB2LTukRZg728Yd1Fj+LuEX75t/hKXE2Ppk8Hh1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.4.tgz", + "integrity": "sha512-ga676Hnvw7/ycdKB53qPusvsKdwrWzEyJ+AtItHGoARszIqvjffTwaaW3b2L6l90i7MO9i+dlAW415INuRhSGg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.4.tgz", + "integrity": "sha512-HP0GDNla1T3ZL8Ko/SHAS2GgtjOg+VmWnnYLhuTksr++EnduYB0f3Y2LzHsUwb2iQ13JGoY6G3R8h6Du/WG6uA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/bootstrap": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.2.tgz", + "integrity": "sha512-D32nmNWiQHo94BKHLmOrdjlL05q1c8oxbtBphQFb9Z5to6eGRDCm0QgeaZ4zFBHzfg2++rqa2JkqCcxDy0sH0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/chart.js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.1.tgz", + "integrity": "sha512-C74QN1bxwV1v2PEujhmKjOZ7iUM4w6BWs23Md/6aOZZSlwMzeCIDGuZay++rBgChYru7/+QFeoQW0fQoP534Dg==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=7" + } + }, + "node_modules/chartjs-adapter-date-fns": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz", + "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==", + "peerDependencies": { + "chart.js": ">=2.8.0", + "date-fns": ">=2.0.0" + } + }, + "node_modules/date-fns": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.1.0.tgz", + "integrity": "sha512-ZO7yefXV/wCWzd3I9haCHmfzlfA3i1a2HHO7ZXjtJrRjXt8FULKJ2Vl8wji3XYF4dQ0ZJ/tokXDZeYlFvgms9Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/esbuild": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.4.tgz", + "integrity": "sha512-x7jL0tbRRpv4QUyuDMjONtWFciygUxWaUM1kMX2zWxI0X2YWOt7MSA0g4UdeSiHM8fcYVzpQhKYOycZwxTdZkA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.19.4", + "@esbuild/android-arm64": "0.19.4", + "@esbuild/android-x64": "0.19.4", + "@esbuild/darwin-arm64": "0.19.4", + "@esbuild/darwin-x64": "0.19.4", + "@esbuild/freebsd-arm64": "0.19.4", + "@esbuild/freebsd-x64": "0.19.4", + "@esbuild/linux-arm": "0.19.4", + "@esbuild/linux-arm64": "0.19.4", + "@esbuild/linux-ia32": "0.19.4", + "@esbuild/linux-loong64": "0.19.4", + "@esbuild/linux-mips64el": "0.19.4", + "@esbuild/linux-ppc64": "0.19.4", + "@esbuild/linux-riscv64": "0.19.4", + "@esbuild/linux-s390x": "0.19.4", + "@esbuild/linux-x64": "0.19.4", + "@esbuild/netbsd-x64": "0.19.4", + "@esbuild/openbsd-x64": "0.19.4", + "@esbuild/sunos-x64": "0.19.4", + "@esbuild/win32-arm64": "0.19.4", + "@esbuild/win32-ia32": "0.19.4", + "@esbuild/win32-x64": "0.19.4" + } + }, + "node_modules/highlight.js": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", + "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", + "engines": { + "node": ">=12.0.0" + } + } + } +} diff --git a/services/app/package.json b/services/app/package.json new file mode 100644 index 0000000..5706844 --- /dev/null +++ b/services/app/package.json @@ -0,0 +1,21 @@ +{ + "name": "albatross", + "version": "0.1.0", + "main": "assets/index.js", + "type": "module", + "author": "nsfisis", + "license": "MIT", + "dependencies": { + "bootstrap": "5.3.2", + "chart.js": "^4.4.1", + "chartjs-adapter-date-fns": "^3.0.0", + "date-fns": "^3.1.0", + "highlight.js": "11.8.0" + }, + "devDependencies": { + "esbuild": "0.19.4" + }, + "scripts": { + "build": "node esbuild.mjs" + } +} diff --git a/services/app/php.local.ini b/services/app/php.local.ini new file mode 100644 index 0000000..0e7b1b5 --- /dev/null +++ b/services/app/php.local.ini @@ -0,0 +1,21 @@ +[PHP] +zend.exception_ignore_args = Off +zend.exception_string_param_max_len = 15 +error_reporting = E_ALL +display_errors = On +display_startup_errors = On + +[Session] +session.use_cookies = 0 +session.name = albatross_session_id +session.cache_limiter = +session.use_strict_mode = On +session.cookie_secure = Off +session.cookie_httponly = On +session.cookie_samesite = Strict +session.save_path = /tmp/session +session.gc_maxlifetime = 3600 +session.cookie_lifetime = 0 + +[Assertion] +zend.assertions = 1 diff --git a/services/app/php.prod.ini b/services/app/php.prod.ini new file mode 100644 index 0000000..33d2dfd --- /dev/null +++ b/services/app/php.prod.ini @@ -0,0 +1,21 @@ +[PHP] +zend.exception_ignore_args = On +zend.exception_string_param_max_len = 0 +error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT +display_errors = Off +display_startup_errors = Off + +[Session] +session.use_cookies = 0 +session.name = albatross_session_id +session.cache_limiter = +session.use_strict_mode = On +session.cookie_secure = On +session.cookie_httponly = On +session.cookie_samesite = Strict +session.save_path = /tmp/session +session.gc_maxlifetime = 604800 +session.cookie_lifetime = 604800 + +[Assertion] +zend.assertions = -1 diff --git a/services/app/phpstan.neon b/services/app/phpstan.neon new file mode 100644 index 0000000..3edc066 --- /dev/null +++ b/services/app/phpstan.neon @@ -0,0 +1,11 @@ +includes: + - phar://phpstan.phar/conf/bleedingEdge.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon + - vendor/phpstan/phpstan-strict-rules/rules.neon + +parameters: + level: max + tipsOfTheDay: false + paths: + - bin + - src diff --git a/services/app/public/.htaccess b/services/app/public/.htaccess new file mode 100644 index 0000000..8661356 --- /dev/null +++ b/services/app/public/.htaccess @@ -0,0 +1,5 @@ +RewriteEngine On + +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] diff --git a/services/app/public/index.php b/services/app/public/index.php new file mode 100644 index 0000000..4461970 --- /dev/null +++ b/services/app/public/index.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +require __DIR__ . '/../vendor/autoload.php'; + +use Nsfisis\Albatross\App; +use Nsfisis\Albatross\Config; + +(new App(Config::fromEnvVars()))->run(); 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 + ] + ]; + } +} diff --git a/services/app/templates/_page.html.twig b/services/app/templates/_page.html.twig new file mode 100644 index 0000000..d2072ea --- /dev/null +++ b/services/app/templates/_page.html.twig @@ -0,0 +1,23 @@ +<!DOCTYPE html> +<html> + <head> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>{{ page_title }} | {{ site_name }}</title> + <link rel="stylesheet" href="{{ base_path() }}/assets/index.css"> + <script type="module" src="{{ base_path() }}/assets/index.js"></script> + </head> + <body> + <header class="container"> + <nav class="navbar"> + <a href="{{ url_for('quiz_list') }}" class="navbar-brand">{{ site_name }}</a> + </nav> + </header> + <main class="container"> + <h1>{{ page_title }}</h1> + {% block content %}{% endblock %} + </main> + <footer class="container"> + {{ site_name }} + </footer> + </body> +</html> diff --git a/services/app/templates/admin_answer_edit.html.twig b/services/app/templates/admin_answer_edit.html.twig new file mode 100644 index 0000000..31d007c --- /dev/null +++ b/services/app/templates/admin_answer_edit.html.twig @@ -0,0 +1,44 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <p> + このページは管理画面です + </p> + <h2>{{ quiz.title }}</h2> + <p> + {{ quiz.description }} + </p> + <h2>回答 #{{ answer.answer_number }}</h2> + <p> + {{ answer.author_name }} が {{ answer.submitted_at|date('Y-m-d H:i:s', 'Asia/Tokyo') }} に投稿 + </p> + <h2>コード</h2> + <p> + {{ answer.code_size }} byte + </p> + <pre><code class="hljs language-php">{{ answer.code }}</code></pre> + <h2>実行結果</h2> + <div> + ステータス: {{ answer.execution_status.label() }} + <form action="{{ url_for('admin_answer_rerun_all_testcases_post', { qslug: quiz.slug, anum: answer.answer_number }) }}" method=POST> + <input type="submit" class="btn btn-warning" value="すべてのテストケースを再実行"> + <input type="hidden" name="{{ csrf.name_key }}" value="{{ csrf.name }}"> + <input type="hidden" name="{{ csrf.value_key }}" value="{{ csrf.value }}"> + </form> + </div> + {% for ex in testcase_executions %} + <h3>テストケース {{ loop.index }}</h3> + <div> + ステータス: {{ ex.status.label() }} + </div> + <form action="{{ url_for('admin_answer_rerun_single_testcase_post', { qslug: quiz.slug, anum: answer.answer_number, txid: ex.testcase_execution_id }) }}" method=POST> + <input type="submit" class="btn btn-warning" value="このテストケースを再実行"> + <input type="hidden" name="{{ csrf.name_key }}" value="{{ csrf.name }}"> + <input type="hidden" name="{{ csrf.value_key }}" value="{{ csrf.value }}"> + </form> + <h4>標準出力</h4> + <pre><code class="hljs language-plaintext">{{ ex.stdout }}</code></pre> + <h4>標準エラー出力</h4> + <pre><code class="hljs language-plaintext">{{ ex.stderr }}</code></pre> + {% endfor %} +{% endblock %} diff --git a/services/app/templates/admin_answer_list.html.twig b/services/app/templates/admin_answer_list.html.twig new file mode 100644 index 0000000..68f0089 --- /dev/null +++ b/services/app/templates/admin_answer_list.html.twig @@ -0,0 +1,41 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <p> + このページは管理画面です。<a href="{{ url_for('answer_list', { qslug: quiz.slug }) }}">通常の回答一覧はこちらを参照してください</a> + </p> + <h2>{{ quiz.title }}</h2> + <p> + {{ quiz.description }} + </p> + <h2>回答一覧</h2> + <form action="{{ url_for('admin_answer_rerun_all_answers_post', { qslug: quiz.slug }) }}" method=POST> + <input type="submit" class="btn btn-warning" value="すべての回答に対して全テストケースを再実行"> + <input type="hidden" name="{{ csrf.name_key }}" value="{{ csrf.name }}"> + <input type="hidden" name="{{ csrf.value_key }}" value="{{ csrf.value }}"> + </form> + <table> + <thead> + <tr> + <th>ランク</th> + <th>ID</th> + <th>作者</th> + <th>サイズ</th> + <th>投稿日時</th> + <th>ステータス</th> + </tr> + </thead> + <tbody> + {% for answer in answers %} + <tr> + <td>{{ loop.index }}</td> + <td><a href="{{ url_for('admin_answer_edit', { qslug: quiz.slug, anum: answer.answer_number }) }}">#{{ answer.answer_number }}</a></td> + <td>{{ answer.author_name }}{% if answer.author_is_admin %} (staff){% endif %}</td> + <td>{{ answer.code_size }} byte</td> + <td>{{ answer.submitted_at|date('Y-m-d H:i:s', 'Asia/Tokyo') }}</td> + <td>{{ answer.execution_status.value }}</td> + </tr> + {% endfor %} + </tbody> + </table> +{% endblock %} diff --git a/services/app/templates/admin_overview.html.twig b/services/app/templates/admin_overview.html.twig new file mode 100644 index 0000000..2103616 --- /dev/null +++ b/services/app/templates/admin_overview.html.twig @@ -0,0 +1,13 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <p> + このページは管理画面です + </p> + <div> + <a href="{{ url_for('admin_user_list') }}">ユーザ一覧</a> + </div> + <div> + <a href="{{ url_for('admin_quiz_list') }}">問題一覧</a> + </div> +{% endblock %} diff --git a/services/app/templates/admin_quiz_edit.html.twig b/services/app/templates/admin_quiz_edit.html.twig new file mode 100644 index 0000000..114de02 --- /dev/null +++ b/services/app/templates/admin_quiz_edit.html.twig @@ -0,0 +1,11 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <p> + このページは管理画面です。<a href="{{ url_for('quiz_view', { qslug: quiz.slug }) }}">通常の問題はこちらを参照してください</a> + </p> + {{ include('form/_form.html.twig') }} + <p> + <a href="{{ url_for('admin_testcase_list', { qslug: quiz.slug }) }}">テストケースを追加・削除・編集する</a> + </p> +{% endblock %} diff --git a/services/app/templates/admin_quiz_list.html.twig b/services/app/templates/admin_quiz_list.html.twig new file mode 100644 index 0000000..4db6961 --- /dev/null +++ b/services/app/templates/admin_quiz_list.html.twig @@ -0,0 +1,21 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <p> + このページは管理画面です。<a href="{{ url_for('quiz_list', { qslug: quiz.slug }) }}">通常の問題一覧はこちらを参照してください</a> + </p> + <ul> + {% for quiz in quizzes %} + <li> + <a href="{{ url_for('admin_quiz_edit', { qslug: quiz.slug }) }}">問題 #{{ quiz.quiz_id }}: {{ quiz.title }}</a> + <ul> + <li><a href="{{ url_for('admin_testcase_list', { qslug: quiz.slug }) }}">テストケース一覧</a></li> + <li><a href="{{ url_for('admin_answer_list', { qslug: quiz.slug }) }}">回答一覧</a></li> + </ul> + </li> + {% endfor %} + </ul> + <p> + <a href="{{ url_for('admin_quiz_new') }}">問題を作成する</a> + </p> +{% endblock %} diff --git a/services/app/templates/admin_quiz_new.html.twig b/services/app/templates/admin_quiz_new.html.twig new file mode 100644 index 0000000..9da6a14 --- /dev/null +++ b/services/app/templates/admin_quiz_new.html.twig @@ -0,0 +1,8 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <p> + このページは管理画面です + </p> + {{ include('form/_form.html.twig') }} +{% endblock %} diff --git a/services/app/templates/admin_testcase_edit.html.twig b/services/app/templates/admin_testcase_edit.html.twig new file mode 100644 index 0000000..e86d61b --- /dev/null +++ b/services/app/templates/admin_testcase_edit.html.twig @@ -0,0 +1,22 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <p> + このページは管理画面です + </p> + {{ include('form/_form.html.twig') }} + <p> + 既存のテストケースを編集すると、この問題に対してすでに提出されている回答に対してもテストが再度実行されます。 + その実行が終わるまで回答のステータスは Pending 状態になり、ランキング等にも出現しなくなります。 + </p> + + <form action="{{ url_for('admin_testcase_delete_post', { qslug: quiz.slug, tid: testcase.testcase_id }) }}" method=POST> + <input type="submit" class="btn btn-danger" value="削除"> + <input type="hidden" name="{{ csrf.name_key }}" value="{{ csrf.name }}"> + <input type="hidden" name="{{ csrf.value_key }}" value="{{ csrf.value }}"> + </form> + <p> + 既存のテストケースを削除すると、この問題に対してすでに提出されている回答のステータスは他のテストケースを基にして再計算されます。 + 再計算が終わるまで回答のステータスは Pending 状態になり、ランキング等にも出現しなくなります。 + </p> +{% endblock %} diff --git a/services/app/templates/admin_testcase_list.html.twig b/services/app/templates/admin_testcase_list.html.twig new file mode 100644 index 0000000..a146406 --- /dev/null +++ b/services/app/templates/admin_testcase_list.html.twig @@ -0,0 +1,18 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <p> + このページは管理画面です + </p> + <h2>{{ quiz.title }}</h2> + <p> + {{ quiz.description }} + </p> + <h2>テストケース一覧</h2> + <ul> + {% for testcase in testcases %} + <li><a href="{{ url_for('admin_testcase_edit', { qslug: quiz.slug, tid: testcase.testcase_id }) }}">テストケース #{{ testcase.testcase_id }}</a></li> + {% endfor %} + </ul> + <a href="{{ url_for('admin_testcase_new', { qslug: quiz.slug }) }}">テストケースを追加する</a> +{% endblock %} diff --git a/services/app/templates/admin_testcase_new.html.twig b/services/app/templates/admin_testcase_new.html.twig new file mode 100644 index 0000000..38243a9 --- /dev/null +++ b/services/app/templates/admin_testcase_new.html.twig @@ -0,0 +1,12 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <p> + このページは管理画面です + </p> + {{ include('form/_form.html.twig') }} + <p> + テストケースを追加すると、この問題に対してすでに提出されている回答に対してもテストが実行されます。 + その実行が終わるまで回答のステータスは Pending 状態になり、ランキング等にも出現しなくなります。 + </p> +{% endblock %} diff --git a/services/app/templates/admin_user_edit.html.twig b/services/app/templates/admin_user_edit.html.twig new file mode 100644 index 0000000..9da6a14 --- /dev/null +++ b/services/app/templates/admin_user_edit.html.twig @@ -0,0 +1,8 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <p> + このページは管理画面です + </p> + {{ include('form/_form.html.twig') }} +{% endblock %} diff --git a/services/app/templates/admin_user_list.html.twig b/services/app/templates/admin_user_list.html.twig new file mode 100644 index 0000000..5d477a9 --- /dev/null +++ b/services/app/templates/admin_user_list.html.twig @@ -0,0 +1,14 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <p> + このページは管理画面です + </p> + <ul> + {% for user in users %} + <li> + <a href="{{ url_for('admin_user_edit', { username: user.username }) }}">{{ user.username }}</a>{% if user.is_admin %} (管理者){% endif %} + </li> + {% endfor %} + </ul> +{% endblock %} diff --git a/services/app/templates/answer_list.html.twig b/services/app/templates/answer_list.html.twig new file mode 100644 index 0000000..410ab20 --- /dev/null +++ b/services/app/templates/answer_list.html.twig @@ -0,0 +1,45 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <h2>{{ quiz.title }}</h2> + <p> + {{ quiz.description }} + </p> + <h2>回答一覧</h2> + {% if answers|length == 0 %} + <p> + まだ回答がありません + </p> + {% else %} + <table> + <thead> + <tr> + <th>ランク</th> + <th>ID</th> + <th>作者</th> + <th>サイズ</th> + <th>投稿日時</th> + <th>ステータス</th> + </tr> + </thead> + <tbody> + {% for answer in answers %} + <tr> + <td> + {% if is_ranking_hidden %} + ? + {% else %} + {{ loop.index }} + {% endif %} + </td> + <td><a href="{{ url_for('answer_view', { qslug: quiz.slug, anum: answer.answer_number }) }}">#{{ answer.answer_number }}</a></td> + <td>{{ answer.author_name }}{% if answer.author_is_admin %} (staff){% endif %}</td> + <td>{{ answer.code_size }} byte</td> + <td>{{ answer.submitted_at|date('Y-m-d H:i:s', 'Asia/Tokyo') }}</td> + <td>{{ answer.execution_status.label() }}</td> + </tr> + {% endfor %} + </tbody> + </table> + {% endif %} +{% endblock %} diff --git a/services/app/templates/answer_new.html.twig b/services/app/templates/answer_new.html.twig new file mode 100644 index 0000000..9dbf96e --- /dev/null +++ b/services/app/templates/answer_new.html.twig @@ -0,0 +1,12 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <h2>{{ quiz.title }}</h2> + <p> + {{ quiz.description }} + </p> + <h3>実装例</h3> + <pre><code class="hljs language-php">{{ quiz.example_code }}</code></pre> + <h3>回答</h3> + {{ include('form/_form.html.twig') }} +{% endblock %} diff --git a/services/app/templates/answer_view.html.twig b/services/app/templates/answer_view.html.twig new file mode 100644 index 0000000..a76e161 --- /dev/null +++ b/services/app/templates/answer_view.html.twig @@ -0,0 +1,38 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <h2>{{ quiz.title }}</h2> + <p> + {{ quiz.description }} + </p> + <h2>回答 #{{ answer.answer_number }}</h2> + <p> + {{ answer.author_name }} が {{ answer.submitted_at|date('Y-m-d H:i:s', 'Asia/Tokyo') }} に投稿 + </p> + <h2>コード</h2> + <p> + {{ answer.code_size }} byte + </p> + <pre><code class="hljs language-php">{{ answer.code }}</code></pre> + <h2>実行結果</h2> + <div> + ステータス: <span class="js-aggregated-execution-status" data-answer-id="{{ answer.answer_id }}">{{ answer.execution_status.label() }}</span> + {% if answer.execution_status.showLoadingIndicator() %} + <div class="js-aggregated-execution-status-loading-indicator spinner-border text-primary spinner-border-sm" role="status"><span class="visually-hidden">Loading...</span></div> + {% endif %} + </div> + {% for ex in testcase_executions %} + <h3>テストケース {{ loop.index }}</h3> + <div> + ステータス: <span class="js-testcase-execution-status" data-testcase-execution-id="{{ ex.testcase_execution_id }}">{{ ex.status.label() }}</span> + {% if ex.status.showLoadingIndicator() %} + <div class="js-testcase-execution-status-loading-indicator spinner-border text-primary spinner-border-sm" role="status" data-testcase-execution-id="{{ ex.testcase_execution_id }}"><span class="visually-hidden">Loading...</span></div> + {% endif %} + </div> + <h4>標準出力</h4> + <pre><code class="js-testcase-execution-stdout hljs language-plaintext" data-testcase-execution-id="{{ ex.testcase_execution_id }}">{{ ex.stdout }}</code></pre> + <h4>標準エラー出力</h4> + <pre><code class="js-testcase-execution-stderr hljs language-plaintext" data-testcase-execution-id="{{ ex.testcase_execution_id }}">{{ ex.stderr }}</code></pre> + {% endfor %} + <script type="module" src="{{ base_path() }}/assets/loading.js"></script> +{% endblock %} diff --git a/services/app/templates/form/_form.html.twig b/services/app/templates/form/_form.html.twig new file mode 100644 index 0000000..b6a9d1d --- /dev/null +++ b/services/app/templates/form/_form.html.twig @@ -0,0 +1,24 @@ +<form method=POST{% if form.action %} action="{{ form.action }}"{% endif %} novalidate> + {% if form.errors.general %} + <div class="alert alert-danger"> + {{ form.errors.general }} + </div> + {% endif %} + + {% for item in form.items %} + {% if item.type == 'checkbox' %} + {{ include('form/_form_item_checkbox.html.twig') }} + {% elseif item.type == 'textarea' %} + {{ include('form/_form_item_textarea.html.twig') }} + {% else %} + {{ include('form/_form_item.html.twig') }} + {% endif %} + {% endfor %} + + <div class="mb-3"> + <input type="submit" class="btn btn-primary" value="{{ form.submit_label }}"> + </div> + + <input type="hidden" name="{{ csrf.name_key }}" value="{{ csrf.name }}"> + <input type="hidden" name="{{ csrf.value_key }}" value="{{ csrf.value }}"> +</form> diff --git a/services/app/templates/form/_form_item.html.twig b/services/app/templates/form/_form_item.html.twig new file mode 100644 index 0000000..6e6a36b --- /dev/null +++ b/services/app/templates/form/_form_item.html.twig @@ -0,0 +1,20 @@ +{% set value = form.state[item.name] %} +{% set error = form.errors[item.name] %} +{% set classes = 'form-control' %} +{% if error %} + {% set classes = classes ~ ' is-invalid' %} +{% endif %} +{% set required = item.isRequired ? ' required' : '' %} +{% set disabled = item.isDisabled ? ' disabled' : '' %} +{% set extra = item.extra ? item.extra : '' %} +<div class="mb-3"> + {% if item.label %} + <label for="form-{{ item.name }}" class="form-label">{{ item.label }}</label> + {% endif %} + + <input type="{{ item.type }}" class="{{ classes }}" id="form-{{ item.name }}" name="{{ item.name }}"{% if value %} value="{{ value }}"{% endif %}{{ required }}{{ disabled }} {{ extra }}> + + {% if error %} + <div class="invalid-feedback">{{ error }}</div> + {% endif %} +</div> diff --git a/services/app/templates/form/_form_item_checkbox.html.twig b/services/app/templates/form/_form_item_checkbox.html.twig new file mode 100644 index 0000000..e51cc0c --- /dev/null +++ b/services/app/templates/form/_form_item_checkbox.html.twig @@ -0,0 +1,21 @@ +{% set value = form.state[item.name] %} +{% set error = form.errors[item.name] %} +{% set classes = 'form-check-input' %} +{% if error %} + {% set classes = classes ~ ' is-invalid' %} +{% endif %} +{% set required = item.isRequired ? ' required' : '' %} +{% set disabled = item.isDisabled ? ' disabled' : '' %} +{% set extra = item.extra ? item.extra : '' %} +{% set extra = extra ~ (value == 'on' ? ' checked' : '') %} +<div class="mb-3 form-check"> + <input type="checkbox" class="{{ classes }}" id="form-{{ item.name }}" name="{{ item.name }}"{% if value %} value="{{ value }}"{% endif %}{{ required }}{{ disabled }} {{ extra }}> + + {% if item.label %} + <label for="form-{{ item.name }}" class="form-check-label">{{ item.label }}</label> + {% endif %} + + {% if error %} + <div class="invalid-feedback">{{ error }}</div> + {% endif %} +</div> diff --git a/services/app/templates/form/_form_item_textarea.html.twig b/services/app/templates/form/_form_item_textarea.html.twig new file mode 100644 index 0000000..bf5cc8c --- /dev/null +++ b/services/app/templates/form/_form_item_textarea.html.twig @@ -0,0 +1,20 @@ +{% set value = form.state[item.name] %} +{% set error = form.errors[item.name] %} +{% set classes = 'form-control' %} +{% if error %} + {% set classes = classes ~ ' is-invalid' %} +{% endif %} +{% set required = item.isRequired ? ' required' : '' %} +{% set disabled = item.isDisabled ? ' disabled' : '' %} +{% set extra = item.extra ? item.extra : '' %} +<div class="mb-3"> + {% if item.label %} + <label for="form-{{ item.name }}" class="form-label">{{ item.label }}</label> + {% endif %} + + <textarea class="{{ classes }}" id="form-{{ item.name }}" name="{{ item.name }}"{{ required }}{{ disabled }} {{ extra }}>{{ value }}</textarea> + + {% if error %} + <div class="invalid-feedback">{{ error }}</div> + {% endif %} +</div> diff --git a/services/app/templates/login.html.twig b/services/app/templates/login.html.twig new file mode 100644 index 0000000..e21b32f --- /dev/null +++ b/services/app/templates/login.html.twig @@ -0,0 +1,5 @@ +{% extends '_page.html.twig' %} + +{% block content %} + {{ include('form/_form.html.twig') }} +{% endblock %} diff --git a/services/app/templates/quiz_list.html.twig b/services/app/templates/quiz_list.html.twig new file mode 100644 index 0000000..2aa4f98 --- /dev/null +++ b/services/app/templates/quiz_list.html.twig @@ -0,0 +1,9 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <ul> + {% for quiz in quizzes %} + <li><a href="{{ url_for('quiz_view', { qslug: quiz.slug }) }}">問題 #{{ quiz.quiz_id }}: {{ quiz.title }}</a></li> + {% endfor %} + </ul> +{% endblock %} diff --git a/services/app/templates/quiz_view.html.twig b/services/app/templates/quiz_view.html.twig new file mode 100644 index 0000000..87c83ce --- /dev/null +++ b/services/app/templates/quiz_view.html.twig @@ -0,0 +1,53 @@ +{% extends '_page.html.twig' %} + +{% block content %} + <h2>{{ quiz.title }}</h2> + <p> + {{ quiz.description }} + </p> + <h3>実装例</h3> + <pre><code class="hljs language-php">{{ quiz.example_code }}</code></pre> + {% if is_open %} + <p> + <a href="{{ url_for('answer_new', { qslug: quiz.slug }) }}">回答する</a> + </p> + {% endif %} + <h2>ランキング</h2> + {% if is_ranking_hidden %} + 回答が締め切られるまで、ランキングは表示されません + {% elseif ranking|length == 0 %} + <p> + まだ正解した回答がありません + </p> + {% else %} + <table> + <thead> + <tr> + <th>ランク</th> + <th>ID</th> + <th>作者</th> + <th>サイズ</th> + <th>投稿日時</th> + </tr> + </thead> + <tbody> + {% for answer in ranking %} + <tr> + <td>{{ loop.index }}</td> + <td><a href="{{ url_for('answer_view', { qslug: quiz.slug, anum: answer.answer_number }) }}">#{{ answer.answer_number }}</a></td> + <td>{{ answer.author_name }}{% if answer.author_is_admin %} (staff){% endif %}</td> + <td>{{ answer.code_size }} byte</td> + <td>{{ answer.submitted_at|date('Y-m-d H:i:s', 'Asia/Tokyo') }}</td> + </tr> + {% endfor %} + </tbody> + </table> + <div> + <canvas id="chart" data-quiz-id="{{ quiz.quiz_id }}"></canvas> + <script type="module" src="{{ base_path() }}/assets/chart.js"></script> + </div> + {% endif %} + <p> + <a href="{{ url_for('answer_list', { qslug: quiz.slug }) }}">すべての回答を見る</a> + </p> +{% endblock %} diff --git a/services/app/tests/AppTest.php b/services/app/tests/AppTest.php new file mode 100644 index 0000000..e6aa1c8 --- /dev/null +++ b/services/app/tests/AppTest.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +use PHPUnit\Framework\TestCase; + +final class AppTest extends TestCase +{ + public function testApp(): void + { + $this->assertTrue(true); + } +} diff --git a/services/sandbox-exec/.dockerignore b/services/sandbox-exec/.dockerignore new file mode 100644 index 0000000..07e6e47 --- /dev/null +++ b/services/sandbox-exec/.dockerignore @@ -0,0 +1 @@ +/node_modules diff --git a/services/sandbox-exec/.gitignore b/services/sandbox-exec/.gitignore new file mode 100644 index 0000000..89c10d6 --- /dev/null +++ b/services/sandbox-exec/.gitignore @@ -0,0 +1,2 @@ +/lib/php-wasm.* +/node_modules diff --git a/services/sandbox-exec/Dockerfile b/services/sandbox-exec/Dockerfile new file mode 100644 index 0000000..1b19e70 --- /dev/null +++ b/services/sandbox-exec/Dockerfile @@ -0,0 +1,83 @@ +FROM emscripten/emsdk:3.1.46 AS wasm-builder + +RUN git clone --depth=1 --branch=php-8.2.10 https://github.com/php/php-src + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + autoconf \ + bison \ + pkg-config \ + re2c \ + && \ + : + +RUN cd php-src && \ + ./buildconf --force && \ + emconfigure ./configure \ + --disable-all \ + --disable-mbregex \ + --disable-fiber-asm \ + --disable-cli \ + --disable-cgi \ + --disable-phpdbg \ + --enable-embed=static \ + --enable-short-tags \ + --enable-mbstring \ + --without-iconv \ + --without-libxml \ + --without-pcre-jit \ + --without-pdo-sqlite \ + --without-sqlite3 \ + && \ + EMCC_CFLAGS='-s ERROR_ON_UNDEFINED_SYMBOLS=0' emmake make -j$(nproc) && \ + mv libs/libphp.a .. && \ + make clean && \ + git clean -fd && \ + : + +COPY wasm/php-wasm.c /src/ + +RUN cd php-src && \ + emcc \ + -c \ + -o php-wasm.o \ + -I . \ + -I TSRM \ + -I Zend \ + -I main \ + ../php-wasm.c \ + && \ + mv php-wasm.o .. && \ + make clean && \ + git clean -fd && \ + : + +RUN emcc \ + -s ENVIRONMENT=node \ + -s ERROR_ON_UNDEFINED_SYMBOLS=0 \ + -s EXPORTED_RUNTIME_METHODS='["ccall"]' \ + -s EXPORT_ES6=1 \ + -s INITIAL_MEMORY=16777216 \ + -s INVOKE_RUN=0 \ + -s MODULARIZE=1 \ + -o php-wasm.js \ + php-wasm.o \ + libphp.a \ + ; + +FROM node:20.7 + +WORKDIR /app + +RUN mkdir /app/lib + +COPY --from=wasm-builder /src/php-wasm.js /src/php-wasm.wasm /app/lib/ + +COPY index.js package.json package-lock.json /app/ +COPY lib/exec.js /app/lib/ + +RUN npm install + +ENTRYPOINT ["node", "index.js"] + +EXPOSE 8888 diff --git a/services/sandbox-exec/index.js b/services/sandbox-exec/index.js new file mode 100644 index 0000000..8c256ab --- /dev/null +++ b/services/sandbox-exec/index.js @@ -0,0 +1,38 @@ +import { fork } from 'node:child_process' +import { serve } from '@hono/node-server' +import { Hono } from 'hono' + +const execPhp = (code, input, timeoutMsec) => { + return new Promise((resolve, _reject) => { + const proc = fork('./lib/exec.js'); + + proc.send({ code, input }); + + proc.on('message', (result) => { + resolve(result); + proc.kill(); + }); + + setTimeout(() => { + resolve({ + status: 'TLE', + stdout: '', + stderr: `Time Limit Exceeded: ${timeoutMsec} msec`, + }); + proc.kill(); + }, timeoutMsec); + }); +}; + +const app = new Hono(); + +app.post('/exec', async (c) => { + const { code, input, timeout } = await c.req.json(); + const result = await execPhp(code, input, timeout); + return c.json(result); +}); + +serve({ + fetch: app.fetch, + port: 8888, +}) diff --git a/services/sandbox-exec/lib/exec.js b/services/sandbox-exec/lib/exec.js new file mode 100644 index 0000000..e37dbb5 --- /dev/null +++ b/services/sandbox-exec/lib/exec.js @@ -0,0 +1,62 @@ +import PHPWasm from './php-wasm.js' + +process.once('message', async ({ code, input }) => { + const PRELUDE = ` + define('STDIN', fopen('php://stdin', 'r')); + define('STDOUT', fopen('php://stdout', 'r')); + define('STDERR', fopen('php://stderr', 'r')); + + `; + const BUFFER_MAX = 1024 * 1024 * 1; + + let stdinPos = 0; // bytewise + let stdinBuf = Buffer.from(input); + let stdoutPos = 0; // bytewise + let stdoutBuf = Buffer.alloc(BUFFER_MAX); + let stderrPos = 0; // bytewise + let stderrBuf = Buffer.alloc(BUFFER_MAX); + + const { ccall } = await PHPWasm({ + stdin: () => { + if (stdinBuf.length <= stdinPos) { + return null; + } + return stdinBuf.readUInt8(stdinPos++); + }, + stdout: (asciiCode) => { + if (asciiCode === null) { + return; // flush + } + if (BUFFER_MAX <= stdoutPos) { + return; // ignore + } + if (asciiCode < 0) { + asciiCode += 256; + } + stdoutBuf.writeUInt8(asciiCode, stdoutPos++); + }, + stderr: (asciiCode) => { + if (asciiCode === null) { + return; // flush + } + if (BUFFER_MAX <= stderrPos) { + return; // ignore + } + if (asciiCode < 0) { + asciiCode += 256; + } + stderrBuf.writeUInt8(asciiCode, stderrPos++); + }, + }); + + const result = ccall( + 'php_wasm_run', + 'number', ['string'], + [PRELUDE + code], + ); + process.send({ + status: result === 0 ? 'AC' : 'RE', + stdout: stdoutBuf.subarray(0, stdoutPos).toString(), + stderr: stderrBuf.subarray(0, stderrPos).toString(), + }); +}); diff --git a/services/sandbox-exec/lib/exec_standalone.js b/services/sandbox-exec/lib/exec_standalone.js new file mode 100644 index 0000000..3b34adb --- /dev/null +++ b/services/sandbox-exec/lib/exec_standalone.js @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs'; +import PHPWasm from './php-wasm.js' + +const userCode = ` +while ($l = fgets(STDIN)) { + echo $l, PHP_EOL; +} +`; + +const PRELUDE = ` +define('STDIN', fopen('php://stdin', 'r')); +define('STDOUT', fopen('php://stdout', 'r')); +define('STDERR', fopen('php://stderr', 'r')); + +`; +const BUFFER_MAX = 1024 * 1024 * 1; + +let stdinPos = 0; // bytewise +let stdinBuf = Buffer.from(readFileSync('/dev/stdin')); +let stdoutPos = 0; // bytewise +let stdoutBuf = Buffer.alloc(BUFFER_MAX); +let stderrPos = 0; // bytewise +let stderrBuf = Buffer.alloc(BUFFER_MAX); + +const { ccall } = await PHPWasm({ + stdin: () => { + if (stdinBuf.length <= stdinPos) { + return null; + } + return stdinBuf.readUInt8(stdinPos++); + }, + stdout: (asciiCode) => { + if (asciiCode === null) { + return; // flush + } + if (BUFFER_MAX <= stdoutPos) { + return; // ignore + } + if (asciiCode < 0) { + asciiCode += 256; + } + stdoutBuf.writeUInt8(asciiCode, stdoutPos++); + }, + stderr: (asciiCode) => { + if (asciiCode === null) { + return; // flush + } + if (BUFFER_MAX <= stderrPos) { + return; // ignore + } + if (asciiCode < 0) { + asciiCode += 256; + } + stderrBuf.writeUInt8(asciiCode, stderrPos++); + }, +}); + +const result = ccall( + 'php_wasm_run', + 'number', ['string'], + [PRELUDE + userCode], +); +console.log({ + status: result === 0 ? 'AC' : 'RE', + stdout: stdoutBuf.subarray(0, stdoutPos).toString(), + stderr: stderrBuf.subarray(0, stderrPos).toString(), +}); diff --git a/services/sandbox-exec/package-lock.json b/services/sandbox-exec/package-lock.json new file mode 100644 index 0000000..332be88 --- /dev/null +++ b/services/sandbox-exec/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "sandbox-exec", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sandbox-exec", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.2.0", + "hono": "^3.7.2" + } + }, + "node_modules/@hono/node-server": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.2.0.tgz", + "integrity": "sha512-aHT8lDMLpd7ioXJ1/057+h+oE/k7rCOWmjklYDsE0jE4CoNB9XzG4f8dRHvw4s5HJFocaYDiGgYM/V0kYbQ0ww==", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/hono": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/hono/-/hono-3.7.2.tgz", + "integrity": "sha512-5SWYrAQJlfjHggcDTnmKZd5zlUEXmoUiBjnmL6C1W8MX39/bUw6ZIvfEJZgpo7d7Z/vCJ5FRfkjIQPRH5yV/dQ==", + "engines": { + "node": ">=16.0.0" + } + } + } +} diff --git a/services/sandbox-exec/package.json b/services/sandbox-exec/package.json new file mode 100644 index 0000000..3c8a857 --- /dev/null +++ b/services/sandbox-exec/package.json @@ -0,0 +1,16 @@ +{ + "name": "sandbox-exec", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "nsfisis", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.2.0", + "hono": "^3.7.2" + } +} diff --git a/services/sandbox-exec/wasm/php-wasm.c b/services/sandbox-exec/wasm/php-wasm.c new file mode 100644 index 0000000..cef661f --- /dev/null +++ b/services/sandbox-exec/wasm/php-wasm.c @@ -0,0 +1,24 @@ +#include <stdio.h> +#include <emscripten.h> +#include <Zend/zend_execute.h> +#include <sapi/embed/php_embed.h> + +int EMSCRIPTEN_KEEPALIVE php_wasm_run(const char* code) { + zend_result result; + + int argc = 1; + char* argv[] = { "php.wasm", NULL }; + + PHP_EMBED_START_BLOCK(argc, argv); + + result = zend_eval_string_ex(code, NULL, "php.wasm code", 1); + + PHP_EMBED_END_BLOCK(); + + fprintf(stdout, "\n"); + fflush(stdout); + fprintf(stderr, "\n"); + fflush(stderr); + + return result == SUCCESS ? 0 : 1; +} |
