diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 22e39bff..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.docker/files/php-school-fpm/Dockerfile b/.docker/files/php-school-fpm/Dockerfile index b6aa0abf..b7028137 100644 --- a/.docker/files/php-school-fpm/Dockerfile +++ b/.docker/files/php-school-fpm/Dockerfile @@ -1,18 +1,22 @@ -FROM php:8.1-fpm +FROM php:8.3-fpm AS prod RUN apt-get -qq update && apt-get install -qqy git zlib1g-dev libzip-dev \ && rm -rf /var/lib/apt/lists/* \ - && docker-php-ext-install pdo pdo_mysql zip + && docker-php-ext-install pdo pdo_mysql zip sockets # Composer RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer -#RUN pecl install xdebug && docker-php-ext-enable xdebug -#ADD .docker/etc/php-xdebug.ini /usr/local/etc/php/conf.d/php-xdebug.ini - COPY . /var/www/html WORKDIR /var/www/html -RUN cd /var/www/html && composer install -q --no-dev -o +COPY --from=php:8.1-cli /usr/local/bin/php-cgi /usr/local/bin/php-cgi + +RUN cd /var/www/html + +CMD ["php-fpm"] + +FROM prod AS debug -CMD ["php-fpm"] \ No newline at end of file +RUN pecl install xdebug && docker-php-ext-enable xdebug +ADD .docker/etc/php-xdebug.ini /usr/local/etc/php/conf.d/php-xdebug.ini \ No newline at end of file diff --git a/.env.dist b/.env.dist index cb8469dd..1a220f52 100644 --- a/.env.dist +++ b/.env.dist @@ -1,10 +1,13 @@ -MYSQL_HOST=localhost +MYSQL_HOST=db MYSQL_DATABASE=phpschool MYSQL_USER=phpschool MYSQL_PASSWORD=phpschool SEND_GRID_API_KEY=sendgripapikey SEND_GRID_SENDER_EMAIL=phpschool.team@gmail.com -REDIS_HOST=localhost -CACHE.ENABLE=true -CACHE.FPC.ENABLE=true -DISPLAY_ERRORS=false \ No newline at end of file +REDIS_HOST=redis +CACHE.ENABLE=false +DISPLAY_ERRORS=true +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_TOKEN= +DEV_MODE=true \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..867eec33 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,17 @@ +/* eslint-env node */ +require('@rushstack/eslint-patch/modern-module-resolution') + +module.exports = { + root: true, + 'extends': [ + 'plugin:vue/vue3-essential', + 'eslint:recommended', + '@vue/eslint-config-prettier/skip-formatting' + ], + parserOptions: { + ecmaVersion: 'latest' + }, + env: { + node: true + } +} diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml new file mode 100644 index 00000000..3845ee15 --- /dev/null +++ b/.github/workflows/eslint.yml @@ -0,0 +1,27 @@ +name: PhpSchool.io + +on: + push: + branches: [ master ] + pull_request: + branches: [ master, online ] + +jobs: + build: + runs-on: ubuntu-latest + name: Eslint + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install NPM Dependencies + run: npm ci --no-audit --no-fund --prefer-offline + + - name: Eslint + run: npm run lint diff --git a/.github/workflows/phpschool.io.yml b/.github/workflows/php.yml similarity index 66% rename from .github/workflows/phpschool.io.yml rename to .github/workflows/php.yml index 64cd55a3..b51ffd98 100644 --- a/.github/workflows/phpschool.io.yml +++ b/.github/workflows/php.yml @@ -4,7 +4,7 @@ on: push: branches: [ master ] pull_request: - branches: [ master ] + branches: [ master, online ] jobs: build: @@ -12,27 +12,27 @@ jobs: strategy: fail-fast: false matrix: - php: [8.1] + php: [8.2, 8.3] name: PHP ${{ matrix.php }} steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - tools: composer:v2 + tools: composer:v2, cs2pr - - name: Install Dependencies + - name: Install PHP Dependencies run: composer install --prefer-dist - name: Run phpunit tests run: composer phpunit - name: Run phpcs - run: composer cs + run: composer cs:ci - - name: Run psalm - run: composer static \ No newline at end of file + - name: Run PHPStan + run: composer static diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml new file mode 100644 index 00000000..8bcfe6d0 --- /dev/null +++ b/.github/workflows/prettier.yml @@ -0,0 +1,27 @@ +name: PhpSchool.io + +on: + push: + branches: [ master ] + pull_request: + branches: [ master, online ] + +jobs: + build: + runs-on: ubuntu-latest + name: Prettier + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install NPM Dependencies + run: npm ci --no-audit --no-fund --prefer-offline + + - name: Prettier + run: npm run prettier:ci diff --git a/.gitignore b/.gitignore index a5dd6fb3..067390d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea node_modules .DS_Store +.php-cs-fixer.cache npm-debug.log /vendor/ /logs/* @@ -8,13 +9,17 @@ npm-debug.log /cache/ /config/database.yml /.phpunit.result.cache - +/*.sql .docker/db/* .docker/mysql/* +/public/dist/**/*.* +!/public/dist/.gitkeep /public/workshops.json /public/uploads /public/blog .env /var -/log \ No newline at end of file +/log + +/assets/.vite-ssg-temp/ \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 00000000..df8b0a6c --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,15 @@ +in(__DIR__ . '/src') + ->in(__DIR__ . '/test') + ->in(__DIR__ . '/app') +; + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PER-CS2.0' => true, + 'declare_strict_types' => true, + 'no_unused_imports' => true, + ]) + ->setFinder($finder); \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..24f1a242 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "semi": false, + "tabWidth": 4, + "singleQuote": true, + "printWidth": 100, + "trailingComma": "none" +} \ No newline at end of file diff --git a/README.md b/README.md index 5fbe0259..4bbb4fc3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ ## Install -You will need `composer`, `gulp` and `docker`. +You will need `composer`, `node` and `docker`. ```shell -composer install npm install +npm run build cp .env.dist .env docker-compose build ``` @@ -12,6 +12,7 @@ docker-compose build ## Run ```shell docker-compose up -d +docker compose exec php composer install ``` ### Create DB Scheme @@ -19,6 +20,11 @@ docker-compose up -d docker compose exec php composer app:db:update ``` +### Import DB +```shell +docker-compose exec -T db mysql -uroot phpschool -proot < phpschool.sql +``` + ### Generate Blog ```shell docker compose exec php composer app:gen:blog @@ -26,20 +32,42 @@ docker compose exec php composer app:gen:blog Then navigate to `http://localhost` ! -Pages are cached on first view. -If you need to clear the cache, run `docker compose exec php composer app:cc`. +## Build CSS & JS -## Build CSS +This needs to be done for the main website (non cloud) to run in development mode. ```shell -gulp sass +npm run build ``` -## Build SVG's +## Building CSS & JS for cloud dev + +The cloud styles and JS are built using `vite.js` and therefore has a dev/watcher mode with hot/live reloading. + +Run: + ```shell -gulp svg +npm run dev ``` +You will also need to symlink the image directory: + +```shell +ln -s ../../assets/img/cloud public/img/cloud +```` + +## For GitHub login + +Add `127.0.0.1 www.phpschool.local` to `/etc/hosts` + +Create a GitHub oauth App: + +Application Name: PHP School Local +Homepage: http://www.phpschool.local +Authorization Callback URL: http://www.phpschool.local/student-login: + +Take the client secret and client ID and place them in your `.env` file under the keys: `GITHUB_CLIENT_ID` & `GITHUB_CLIENT_SECRET`. + ### View cache keys ```shell @@ -49,7 +77,7 @@ docker-compose exec redis redis-cli keys '*' ### Clear cache ```shell -docker compose exec php composer app:cc +docker-compose exec php composer app:cc ``` ## Deploy diff --git a/app/config.php b/app/config.php index ff0f1e8f..10054382 100644 --- a/app/config.php +++ b/app/config.php @@ -1,105 +1,149 @@ factory(function (DI\Container $c): Silly\Edition\PhpDi\Application { $app = new Silly\Edition\PhpDi\Application('PHP School Website', 'UNKNOWN', $c); $app->command('clear-cache', ClearCache::class); - $app->command('create-user name email password', CreateUser::class); + $app->command('create-admin-user name email password', CreateAdminUser::class); $app->command('generate-blog', GenerateBlog::class); + $app->command('download-composer-packages', DownloadComposerPackageList::class); + $app->command('sync-contributors', SyncContributors::class); ConsoleRunner::addCommands($app, new SingleManagerProvider($c->get(EntityManagerInterface::class))); return $app; }), + 'basePath' => __DIR__ . '/../', 'app' => factory(function (ContainerInterface $c): App { $app = Bridge::create($c); $app->addRoutingMiddleware(); - $app->add($c->get(FpcCache::class)); + + $app->add(function (Request $request, RequestHandler $handler) use ($c): Response { + /** @var Session $session */ + $session = $this->get(Session::class); + + $student = $session->get('student'); + + $request = $request->withAttribute('student', $student); + + return $handler->handle($request) + ->withHeader('cache-control', 'no-cache'); + }); + $app->add(StudentRefresher::class); $app->add(new SessionMiddleware(['name' => 'phpschool'])); return $app; }), - FpcCache::class => factory(function (ContainerInterface $c): FpcCache { - return new FpcCache($c->get('cache.fpc')); - }), - 'cache.fpc' => factory(function (ContainerInterface $c): CacheInterface { - if (!$c->get('config')['enablePageCache']) { - return new NullAdapter; - } - return new RedisAdapter(new Predis\Client(['host' => $c->get('config')['redisHost']]), 'fpc'); - }), 'cache' => factory(function (ContainerInterface $c): CacheInterface { if (!$c->get('config')['enableCache']) { - return new NullAdapter; + return new NullAdapter(); } $redisConnection = new \Predis\Client(['host' => $c->get('config')['redisHost']]); @@ -117,35 +161,18 @@ return new RedisAdapter($redisConnection, 'default'); }), - PhpRenderer::class => factory(function (ContainerInterface $c): PhpRenderer { - $settings = $c->get('config')['renderer']; - $renderer = new PhpRenderer( - $settings['template_path'], - [ - 'links' => $c->get('config')['links'], - ] - ); - - //default CSS - $renderer->appendLocalCss('main-css', __DIR__ . '/../public/css/core.css'); - $renderer->appendRemoteCss('font', 'https://fonts.googleapis.com/css?family=Open+Sans: 400,700'); - - //default JS - $renderer->addJs('jquery', '//code.jquery.com/jquery-1.12.0.min.js'); - $renderer->addJs('main-js', '/js/main.min.js'); - - return $renderer; - }), - LoggerInterface::class => factory(function (ContainerInterface $c): LoggerInterface{ + LoggerInterface::class => factory(function (ContainerInterface $c): LoggerInterface { $settings = $c->get('config')['logger']; $logger = new Logger($settings['name']); - $logger->pushProcessor(new UidProcessor); + $logger->pushProcessor(new UidProcessor()); $logger->pushHandler(new StreamHandler($settings['path'], Logger::DEBUG)); return $logger; }), + SessionStorageInterface::class => get(Session::class), + Session::class => function (ContainerInterface $c): Session { - return new Session; + return new Session(); }, FormHandlerFactory::class => function (ContainerInterface $c): FormHandlerFactory { @@ -154,55 +181,20 @@ //commands ClearCache::class => factory(function (ContainerInterface $c): ClearCache { - return new ClearCache($c->get('cache.fpc')); + return new ClearCache($c->get('cache')); }), - CreateUser::class => factory(function (ContainerInterface $c): CreateUser { - return new CreateUser($c->get(EntityManagerInterface::class)); + CreateAdminUser::class => factory(function (ContainerInterface $c): CreateAdminUser { + return new CreateAdminUser($c->get(EntityManagerInterface::class)); }), GenerateBlog::class => function (ContainerInterface $c): GenerateBlog { return new GenerateBlog($c->get(Generator::class)); }, - - Documentation::class => \DI\factory(function (ContainerInterface $c): Documentation { - $tutorialGroup = new DocumentationGroup('tutorial', 'Workshop Tutorial'); - $tutorialGroup->addSection('index', 'Workshop Tutorial', 'docs/tutorial/index.phtml'); - $tutorialGroup->addSection('creating-your-own-workshop', 'Creating your own workshop', 'docs/tutorial/creating-your-own-workshop.phtml'); - $tutorialGroup->addSection('modify-theme', 'Modifying the theme of your workshop', 'docs/tutorial/modify-theme.phtml'); - $tutorialGroup->addSection('creating-an-exercise', 'Creating an exercise', 'docs/tutorial/creating-an-exercise.phtml'); - - $referenceGroup = new DocumentationGroup('reference', 'Reference Documentation'); - $referenceGroup->addSection('index', 'Reference Documentation', 'docs/reference/index.phtml'); - $referenceGroup->addSection('container', 'The Container', 'docs/reference/container.phtml'); - $referenceGroup->addSection('available-services', 'Available Services', 'docs/reference/available-services.phtml'); - $referenceGroup->addSection('exercise-types', 'Exercise Types', 'docs/reference/exercise-types.phtml'); - $referenceGroup->addSection('exercise-solutions', 'Exercise Solutions', 'docs/reference/exercise-solutions.phtml'); - $referenceGroup->addSection('results', 'Results & Renderers', 'docs/reference/results.phtml'); - $referenceGroup->addSection('exercise-checks', 'Exercise Checks', 'docs/reference/exercise-checks.phtml'); - $referenceGroup->addSection('bundled-checks', 'Bundled Checks', 'docs/reference/bundled-checks.phtml'); - $referenceGroup->addSection('creating-simple-checks', 'Creating Simple Checks', 'docs/reference/creating-simple-checks.phtml'); - $referenceGroup->addSection('creating-custom-results', 'Creating Custom Results', 'docs/reference/creating-custom-results.phtml'); - $referenceGroup->addSection('creating-custom-result-renderers', 'Creating Custom Result Renderers', 'docs/reference/creating-custom-result-renderers.phtml'); - $referenceGroup->addSection('events', 'Events', 'docs/reference/events.phtml'); - $referenceGroup->addSection('creating-listener-checks', 'Creating Listener Checks', 'docs/reference/creating-listener-checks.phtml'); - $referenceGroup->addSection('self-checking-exercises', 'Self Checking Exercises', 'docs/reference/self-checking-exercises.phtml'); - $referenceGroup->addSection('exercise-events', 'Exercise Events', 'docs/reference/exercise-events.phtml'); - $referenceGroup->addSection('patching-exercise-solutions', 'Patching Exercise Submissions', 'docs/reference/patching-exercise-solutions.phtml'); - - - $indexGroup = new DocumentationGroup('index', 'Documentation Home'); - $indexGroup->addSection('index', 'Documentation Home', 'docs/index.phtml'); - - $docs = new Documentation; - $docs->addGroup($indexGroup); - $docs->addGroup($tutorialGroup); - $docs->addGroup($referenceGroup); - - return $docs; - }), - - DocsAction::class => \DI\factory(function (ContainerInterface $c): DocsAction { - return new DocsAction($c->get(PhpRenderer::class), $c->get(Documentation::class)); - }), + DownloadComposerPackageList::class => function (ContainerInterface $c): DownloadComposerPackageList { + return new DownloadComposerPackageList($c->get('guzzle.packagist'), $c->get(LoggerInterface::class)); + }, + SyncContributors::class => function (ContainerInterface $c): SyncContributors { + return new SyncContributors($c->get(Client::class), $c->get(LoggerInterface::class)); + }, TrackDownloads::class => function (ContainerInterface $c): TrackDownloads { return new TrackDownloads($c->get(WorkshopRepository::class), $c->get(WorkshopInstallRepository::class)); @@ -211,34 +203,61 @@ SubmitWorkshop::class => \DI\factory(function (ContainerInterface $c): SubmitWorkshop { return new SubmitWorkshop( $c->get(FormHandlerFactory::class)->create( - new SubmitWorkshopInputFilter(new Client, $c->get(WorkshopRepository::class)) + new SubmitWorkshopInputFilter(new Client(), $c->get(WorkshopRepository::class)) ), - new WorkshopCreator(new WorkshopComposerJsonInputFilter, $c->get(WorkshopRepository::class)), + new WorkshopCreator(new WorkshopComposerJsonInputFilter(), $c->get(WorkshopRepository::class)), $c->get(EmailNotifier::class), $c->get(LoggerInterface::class) ); }), + SlackInvite::class => function (ContainerInterface $c): SlackInvite { + return new SlackInvite( + $c->get('guzzle'), + $c->get('config')['slackInviteApiToken'] + ); + }, + + Client::class => function (ContainerInterface $c): Client { + $client = new Client(); + $client->authenticate($c->get('config')['github']['token'], AuthMethod::ACCESS_TOKEN); + + return $client; + }, + + Github::class => function (ContainerInterface $c): Github { + return new Github([ + 'clientId' => $c->get('config')['github']['clientId'], + 'clientSecret' => $c->get('config')['github']['clientSecret'], + ]); + }, + + StudentLogin::class => function (ContainerInterface $c): StudentLogin { + return new StudentLogin( + $c->get(Github::class), + $c->get(Session::class), + $c->get(EntityManagerInterface::class) + ); + }, + //admin Login::class => \DI\factory(function (ContainerInterface $c): Login { return new Login( - $c->get(AuthenticationService::class), - $c->get(FormHandlerFactory::class)->create(new LoginInputFilter), - $c->get(PhpRenderer::class) + $c->get(AdminAuthenticationService::class), + $c->get(FormHandlerFactory::class)->create(new LoginInputFilter()), + $c->get('config')['jwtSecret'] ); }), ClearCacheAction::class => function (ContainerInterface $c): ClearCacheAction { return new ClearCacheAction( - $c->get('cache.fpc'), - $c->get(Messages::class) + $c->get('cache'), ); }, Requests::class => \DI\factory(function (ContainerInterface $c): Requests { return new Requests( $c->get(WorkshopRepository::class), - $c->get(PhpRenderer::class) ); }), @@ -246,7 +265,6 @@ return new All( $c->get(WorkshopRepository::class), $c->get(WorkshopInstallRepository::class), - $c->get(PhpRenderer::class) ); }), @@ -254,8 +272,7 @@ return new Approve( $c->get(WorkshopRepository::class), $c->get(WorkshopFeed::class), - $c->get('cache.fpc'), - $c->get(Messages::class), + $c->get('cache'), $c->get(EmailNotifier::class), $c->get(LoggerInterface::class) ); @@ -265,8 +282,7 @@ return new Promote( $c->get(WorkshopRepository::class), $c->get(WorkshopFeed::class), - $c->get('cache.fpc'), - $c->get(Messages::class) + $c->get('cache'), ); }), @@ -275,8 +291,7 @@ $c->get(WorkshopRepository::class), $c->get(WorkshopInstallRepository::class), $c->get(WorkshopFeed::class), - $c->get('cache.fpc'), - $c->get(Messages::class) + $c->get('cache'), ); }), @@ -284,24 +299,97 @@ return new View( $c->get(WorkshopRepository::class), $c->get(WorkshopInstallRepository::class), - $c->get(PhpRenderer::class) ); }, + 'guzzle' => function (ContainerInterface $c): \GuzzleHttp\Client { + return new \GuzzleHttp\Client(); + }, + + 'guzzle.packagist' => function (ContainerInterface $c) { + return new \GuzzleHttp\Client(['headers' => ['User-Agent' => 'PHP School: phpschool.team@gmail.com']]); + }, + + ComposerPackageAdd::class => function (ContainerInterface $c): ComposerPackageAdd { + return new ComposerPackageAdd( + new PackagistLatestVersion($c->get('guzzle.packagist')), + ); + }, + + CurrentContext::class => function (): CurrentContext { + return CurrentContext::cloud(); + }, + + //cloud + MarkdownConverterInterface::class => function (ContainerInterface $c): MarkdownConverterInterface { + $environment = new \League\CommonMark\Environment([ + 'external_link' => [ + 'internal_hosts' => 'www.phpschool.io', + 'open_in_new_window' => true, + 'nofollow' => '', + 'noopener' => 'external', + 'noreferrer' => 'external', + ], + ]); + + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new GithubFlavoredMarkdownExtension()); + $environment->addExtension(new ExternalLinkExtension()); + + $environment + ->addExtension(new ProblemFileExtension( + $c->get(ContextSpecificRenderer::class), + [ + new AppName(), + new DocumentationShorthand(), + new Run(), + new Verify(), + $c->get(Context::class) + ] + )); + + return new MarkdownConverter($environment); + }, + + ProblemFileConverter::class => function (ContainerInterface $c): ProblemFileConverter { + return new ProblemFileConverter($c->get(MarkdownConverterInterface::class)); + }, + + RunExercise::class => function (ContainerInterface $c): RunExercise { + return new RunExercise( + $c->get(CloudWorkshopRepository::class), + $c->get(ProjectUploader::class), + $c->get(StudentWorkshopState::class), + $c->get(SessionStorageInterface::class), + ); + }, + + VerifyExercise::class => function (ContainerInterface $c): VerifyExercise { + return new VerifyExercise( + $c->get(CloudWorkshopRepository::class), + $c->get(ProjectUploader::class), + $c->get(SessionStorageInterface::class), + $c->get(StudentWorkshopState::class), + new VueResultsRenderer() + ); + }, + + ProjectUploader::class => function (ContainerInterface $c): ProjectUploader { + return new ProjectUploader(new PathGenerator()); + }, + 'form.event' => function (ContainerInterface $c): FormHandler { - return $c->get(FormHandlerFactory::class)->create(new EventInputFilter); + return $c->get(FormHandlerFactory::class)->create(new EventInputFilter()); }, EventAll::class => function (ContainerInterface $c): EventAll { - return new EventAll($c->get(EventRepository::class), $c->get(PhpRenderer::class)); + return new EventAll($c->get(EventRepository::class)); }, EventCreate::class => function (ContainerInterface $c): EventCreate { return new EventCreate( $c->get(EventRepository::class), $c->get('form.event'), - $c->get(PhpRenderer::class), - $c->get(Messages::class) ); }, @@ -309,23 +397,16 @@ return new EventUpdate( $c->get(EventRepository::class), $c->get('form.event'), - $c->get(PhpRenderer::class), - $c->get(Messages::class) ); }, EventDelete::class => function (ContainerInterface $c): EventDelete { return new EventDelete( $c->get(EventRepository::class), - $c->get('cache.fpc'), - $c->get(Messages::class) + $c->get('cache'), ); }, - Messages::class => \DI\factory(function (ContainerInterface $c): Messages { - return new Messages(); - }), - WorkshopFeed::class => \DI\factory(function (ContainerInterface $c): WorkshopFeed { return new WorkshopFeed( $c->get(WorkshopRepository::class), @@ -345,16 +426,28 @@ return $c->get(EntityManagerInterface::class)->getRepository(Event::class); }, - AuthenticationService::class => \DI\factory(function (ContainerInterface $c): AuthenticationService { - $authService = new \Laminas\Authentication\AuthenticationService; - $authService->setAdapter(new Doctrine($c->get(EntityManagerInterface::class))); - return new AuthenticationService($authService); - }), + DoctrineORMBlogRepository::class => function (ContainerInterface $c): DoctrineORMBlogRepository { + return $c->get(EntityManagerInterface::class)->getRepository(BlogPost::class); + }, + + StudentRepository::class => function (ContainerInterface $c): StudentRepository { + return $c->get(EntityManagerInterface::class)->getRepository(Student::class); + }, - Authenticator::class => \DI\factory(function (ContainerInterface $c): Authenticator { - return new Authenticator($c->get(AuthenticationService::class)); + AdminAuthenticationService::class => \DI\factory(function (ContainerInterface $c): AdminAuthenticationService { + $authService = new \Laminas\Authentication\AuthenticationService( + new \Laminas\Authentication\Storage\NonPersistent(), + new Doctrine($c->get(EntityManagerInterface::class)) + ); + return new AdminAuthenticationService($authService); }), + StudentAuthenticator::class => function (ContainerInterface $c): StudentAuthenticator { + return new StudentAuthenticator( + $c->get(Session::class), + ); + }, + ORMSetup::class => \DI\factory(function (ContainerInterface $c): Configuration { $doctrineConfig = $c->get('config')['doctrine']; @@ -375,8 +468,13 @@ EntityManagerInterface::class => \DI\factory(function (ContainerInterface $c): EntityManagerInterface { Type::addType('uuid', UuidType::class); - return EntityManager::create( + + $driver = \Doctrine\DBAL\DriverManager::getConnection( $c->get('config')['doctrine']['connection'], + ); + + return new EntityManager( + $driver, $c->get(ORMSetup::class) ); }), @@ -390,13 +488,64 @@ Generator::class => function (ContainerInterface $c): Generator { return new Generator( - new Parser, + new Parser(null, new class () implements \Mni\FrontYAML\Markdown\MarkdownParser { + public function parse($markdown): string + { + return (new Parsedown())->parse($markdown); + } + }), + $c->get(DoctrineORMBlogRepository::class), __DIR__ . '/../posts/', - __DIR__ . '/../public/blog', - $c->get(PhpRenderer::class) ); }, + CloudWorkshopRepository::class => function (ContainerInterface $c): CloudWorkshopRepository { + return new CloudWorkshopRepository($c->get(WorkshopRepository::class)); + }, + + 'exerciseRunnerRateLimiterFactory' => function (ContainerInterface $c): RateLimiterFactory { + $redisConnection = new \Predis\Client(['host' => $c->get('config')['redisHost']]); + try { + $redisConnection->connect(); + } catch (ConnectionException $e) { + throw new \RuntimeException( + sprintf( + 'Could not connect to redis using host: "%s". Message: "%s"', + $c->get('config')['redisHost'], + $e->getMessage() + ) + ); + } + + $adapter = new RedisAdapter($redisConnection, 'rate_limiter'); + + return new RateLimiterFactory( + [ + 'id' => 'exerciseRunner', + 'policy' => 'sliding_window', + 'limit' => 10, + 'interval' => '1 minute', + ], + new CacheStorage($adapter) + ); + }, + + ExerciseRunnerRateLimiter::class => function (ContainerInterface $c): ExerciseRunnerRateLimiter { + return new ExerciseRunnerRateLimiter( + $c->get(SessionStorageInterface::class), + $c->get('exerciseRunnerRateLimiterFactory') + ); + }, + + JwtAuthentication::class => function (ContainerInterface $c): JwtAuthentication { + return new JwtAuthentication([ + 'secret' => $c->get('config')['jwtSecret'], + 'path' => '/api/admin', + "ignore" => ["/api/admin/login"], + "secure" => !$c->get('config')['devMode'], + ]); + }, + 'config' => [ 'containerCacheDir' => __DIR__ . '/../var/container_cache', @@ -420,9 +569,9 @@ 'github-website' => 'https://github.com/php-school/phpschool.io', ], - 'enablePageCache' => filter_var($_ENV['CACHE.FPC.ENABLE'], FILTER_VALIDATE_BOOLEAN), 'enableCache' => filter_var($_ENV['CACHE.ENABLE'], FILTER_VALIDATE_BOOLEAN), 'redisHost' => $_ENV['REDIS_HOST'], + 'devMode' => filter_var($_ENV['DEV_MODE'], FILTER_VALIDATE_BOOLEAN), 'doctrine' => [ 'meta' => [ @@ -431,7 +580,7 @@ 'src/User/Entity', ], 'auto_generate_proxies' => true, - 'proxy_dir' => __DIR__.'/../cache/proxies', + 'proxy_dir' => __DIR__ . '/../cache/proxies', ], 'connection' => [ 'driver' => 'pdo_mysql', @@ -439,8 +588,18 @@ 'dbname' => $_ENV['MYSQL_DATABASE'], 'user' => $_ENV['MYSQL_USER'], 'password' => $_ENV['MYSQL_PASSWORD'], + 'charset' => 'utf8mb4', ] - ] + ], + + 'github' => [ + 'clientId' => $_ENV['GITHUB_CLIENT_ID'], + 'clientSecret' => $_ENV['GITHUB_CLIENT_SECRET'], + 'token' => $_ENV['GITHUB_TOKEN'], + ], + + 'jwtSecret' => $_ENV['JWT_SECRET'], + 'slackInviteApiToken' => $_ENV['SLACK_INVITE_API_TOKEN'], ], //slim settings diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 00000000..1f1d65c1 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,236 @@ +import "vite/modulepreload-polyfill"; +import "./styles"; +import { FocusTrap } from "focus-trap-vue"; +import VueClickAway from "vue3-click-away"; +import VueDiff from "vue-diff"; +import "vue-diff/dist/index.css"; +import { markRaw } from "vue"; +import VueShepherdPlugin from "./shepherd-plugin"; +import results from "./components/Online/Results/results.js"; +import Home from "./components/Website/Pages/PageHome.vue"; +import SubmitWorkshop from "./components/Website/Pages/PageSubmitWorkshop.vue"; + +import Offline from "./components/Website/Pages/PageOffline.vue"; +import Docs from "./components/Website/Pages/PageDocs.vue"; + +import App from "./components/Website/App.vue"; + +import { docs } from "./components/Website/Docs/contents.js"; +import MainLayout from "./components/Website/MainLayout.vue"; +import CompactLayout from "./components/Website/CompactLayout.vue"; + +import Dashboard from "./components/Online/PageDashboard.vue"; +import Events from "./components/Website/Pages/PageEvents.vue"; +import BlogIndex from "./components/Website/Pages/PageBlog.vue"; +import BlogPost from "./components/Website/Pages/PageBlogPost.vue"; +import { ViteSSG } from "vite-ssg"; +import { createPinia } from "pinia"; +import { useBlogStore } from "./stores/blog"; +import { useEventStore } from "./stores/events"; + +import { useAdminStore } from "./stores/admin"; +import AdminLayout from "./components/Admin/AdminLayout.vue"; +import EmptyAdminLayout from "./components/Admin/EmptyLayout.vue"; +import AdminLogin from "./components/Admin/PageLogin.vue"; +import AminHome from "./components/Admin/PageHome.vue"; +import AdminWorkshops from "./components/Admin/PageWorkshops.vue"; +import AdminWorkshop from "./components/Admin/PageWorkshop.vue"; +import AdminWorkshopInstalls from "./components/Admin/PageWorkshopInstalls.vue"; +import AdminNewWorkshops from "./components/Admin/PageNewWorkshops.vue"; +import AdminStudents from "./components/Admin/PageStudents.vue"; +import AdminSettings from "./components/Admin/PageSettings.vue"; +import AdminEvents from "./components/Admin/PageEvents.vue"; +import { useStudentStore } from "./stores/student"; +import { useWorkshopStore } from "./stores/workshops"; +import ExerciseEditor from "./components/Online/PageExerciseEditor.vue"; + +const docRoutes = [].concat( + ...docs.map((doc) => { + return doc.sections.map((section) => { + const parts = ["docs", doc.path, section.path]; + + return { + path: "/" + parts.filter((part) => part !== "").join("/"), + component: section.component, + meta: { section: section, group: doc }, + }; + }); + }), +); + +const routes = [ + { path: "/", component: Home, meta: { layout: MainLayout } }, + { path: "/online/:workshop?", component: Dashboard, meta: { layout: CompactLayout } }, + { + path: "/online/editor/:workshop/:exercise", + component: ExerciseEditor, + name: "editor", + props: true, + meta: { layout: CompactLayout }, + }, + { path: "/offline", component: Offline, meta: { layout: MainLayout } }, + { path: "/submit", component: SubmitWorkshop, meta: { layout: MainLayout } }, + { + path: "/docs", + component: Docs, + children: docRoutes, + meta: { layout: MainLayout }, + }, + { + path: "/events", + component: Events, + name: "events", + meta: { layout: MainLayout }, + }, + { + path: "/blog", + component: BlogIndex, + name: "blog", + meta: { layout: MainLayout }, + }, + { + path: "/blog/:page(\\d+)?", + component: BlogIndex, + props: true, + meta: { layout: MainLayout }, + }, + { + path: "/blog/:slug", + component: BlogPost, + name: "blog-post", + props: true, + meta: { layout: MainLayout }, + }, + { + path: "/login", + component: AdminLogin, + name: "admin-login", + props: true, + meta: { layout: EmptyAdminLayout }, + }, + { + path: "/admin", + component: AminHome, + name: "admin", + props: true, + meta: { layout: AdminLayout }, + }, + { + path: "/admin/workshops", + component: AdminWorkshops, + props: true, + meta: { layout: AdminLayout }, + }, + { + path: "/admin/workshop/:id", + component: AdminWorkshop, + props: true, + meta: { layout: AdminLayout }, + }, + { + path: "/admin/workshop-installs", + component: AdminWorkshopInstalls, + props: true, + meta: { layout: AdminLayout }, + }, + { + path: "/admin/new-workshops", + component: AdminNewWorkshops, + props: true, + meta: { layout: AdminLayout }, + }, + { + path: "/admin/students", + component: AdminStudents, + props: true, + meta: { layout: AdminLayout }, + }, + { + path: "/admin/settings", + component: AdminSettings, + props: true, + meta: { layout: AdminLayout }, + }, + { + path: "/admin/events", + component: AdminEvents, + props: true, + meta: { layout: AdminLayout }, + }, +]; + +export const createApp = ViteSSG( + App, + { + routes, + scrollBehavior() { + // always scroll to top + return { top: 0 }; + }, + }, + async ({ app, router, isClient, initialState, onSSRAppRendered }) => { + Object.entries(results).forEach(([name, resultComponent]) => { + app.component(name, resultComponent); + }); + app.component("FocusTrap", FocusTrap); + + app.use(VueClickAway); + app.use(VueDiff); + app.use(VueShepherdPlugin); + const pinia = createPinia(); + pinia.use(({ store }) => { + store.router = markRaw(router); + }); + app.use(pinia); + + if (isClient) { + pinia.state.value = initialState.pinia || {}; + + const studentStore = useStudentStore(pinia); + await studentStore.initialize(); + } + + if (!isClient || import.meta.env.DEV) { + const blogStore = useBlogStore(pinia); + await blogStore.initialize(); + + const eventStore = useEventStore(pinia); + await eventStore.initialize(); + + const workshopStore = useWorkshopStore(pinia); + await workshopStore.initialize(); + + onSSRAppRendered(() => { + initialState.pinia = pinia.state.value; + }); + } + + const adminStore = useAdminStore(pinia); + + router.beforeEach(async (to) => { + //if not admin route and not login route, skip, it's a non-authenticated route + if (!to.path.startsWith("/admin") && to.name !== "admin-login") { + return; + } + + //if we are not logged in redirect to login page + if (!adminStore.admin && to.name !== "admin-login") { + return { name: "admin-login" }; + } + }); + }, +); + +export async function includedRoutes(paths, routes) { + let pathsToRender = routes.filter((route) => { + return route.name === "blog" || route.name === "blog-post" || route.name === "events"; + }); + + const response = await fetch(import.meta.env.VITE_API_URL + "/api/posts"); + const posts = await response.json(); + const slugs = posts.posts.map((post) => post.slug); + + return pathsToRender.flatMap((route) => { + return route.name === "blog-post" ? slugs.map((slug) => `/blog/${slug}`) : route.path; + }); +} diff --git a/assets/components/Admin/AdminLayout.vue b/assets/components/Admin/AdminLayout.vue new file mode 100644 index 00000000..7a260e12 --- /dev/null +++ b/assets/components/Admin/AdminLayout.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/assets/components/Admin/EmptyLayout.vue b/assets/components/Admin/EmptyLayout.vue new file mode 100644 index 00000000..ece0df7d --- /dev/null +++ b/assets/components/Admin/EmptyLayout.vue @@ -0,0 +1,5 @@ + diff --git a/assets/components/Admin/PageEvents.vue b/assets/components/Admin/PageEvents.vue new file mode 100644 index 00000000..70d8d934 --- /dev/null +++ b/assets/components/Admin/PageEvents.vue @@ -0,0 +1,483 @@ + +