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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions src/Action/Online/RunExercise.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@
use PhpSchool\PhpWorkshop\Event\CliExecuteEvent;
use PhpSchool\PhpWorkshop\Event\Event;
use PhpSchool\PhpWorkshop\Exception\CouldNotRunException;
use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface;
use PhpSchool\PhpWorkshop\Input\Input;
use PhpSchool\PhpWorkshop\Output\BufferedOutput;
use PhpSchool\PhpWorkshop\Solution\DirectorySolution;
use PhpSchool\Website\Action\JsonUtils;
use PhpSchool\Website\Online\CloudInstalledWorkshop;
use PhpSchool\Website\Online\CloudWorkshopRepository;
use PhpSchool\Website\Online\ProjectUploader;
use PhpSchool\Website\Online\Sandbox\SandboxedCliRunner;
use PhpSchool\Website\Online\StudentWorkshopState;
use PhpSchool\Website\User\SessionStorageInterface;
use PhpSchool\Website\User\StudentDTO;
Expand Down Expand Up @@ -61,6 +65,20 @@ public function __invoke(
return $this->withJson(['success' => false, 'error' => $e->getMessage()], $response);
}

try {
return $this->runProject($workshop, $exercise, $project, $response);
} finally {
$this->runInfo = [];
(new Filesystem())->remove($project->getBaseDirectory());
}
}

private function runProject(
CloudInstalledWorkshop $workshop,
ExerciseInterface $exercise,
DirectorySolution $project,
Response $response
): Response {
$output = new BufferedOutput();

$workshop->getExerciseDispatcher()
Expand All @@ -69,11 +87,13 @@ public function __invoke(
$this->collectRunInfo($event, $output);
});


try {
$result = $workshop->getExerciseDispatcher()->run(
$exercise,
new Input($workshop->getCode(), ['program' => $project->getEntryPoint()->getAbsolutePath()]),
new Input($workshop->getCode(), [
'program' => $project->getEntryPoint()->getAbsolutePath(),
SandboxedCliRunner::ROOT_ARGUMENT => $project->getBaseDirectory(),
]),
$output
);
} catch (CouldNotRunException $e) {
Expand All @@ -83,16 +103,10 @@ public function __invoke(
], $response);
}

$data = [
return $this->withJson([
'runs' => $this->runInfo,
'success' => $result
];

$this->runInfo = [];

(new Filesystem())->remove($project->getBaseDirectory());

return $this->withJson($data, $response);
], $response);
}

private function getStudent(): StudentDTO
Expand Down
19 changes: 12 additions & 7 deletions src/Action/Online/VerifyExercise.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use PhpSchool\Website\Action\JsonUtils;
use PhpSchool\Website\Online\CloudWorkshopRepository;
use PhpSchool\Website\Online\ProjectUploader;
use PhpSchool\Website\Online\Sandbox\SandboxedCliRunner;
use PhpSchool\Website\Online\StudentWorkshopState;
use PhpSchool\Website\Online\VueResultsRenderer;
use PhpSchool\Website\User\SessionStorageInterface;
Expand Down Expand Up @@ -49,10 +50,17 @@ public function __invoke(
return $this->withJson(['success' => false, 'error' => $e->getMessage()], $response);
}

$results = $workshop->getExerciseDispatcher()->verify(
$exercise,
new Input($workshop->getCode(), ['program' => $project->getEntryPoint()->getAbsolutePath()]),
);
try {
$results = $workshop->getExerciseDispatcher()->verify(
$exercise,
new Input($workshop->getCode(), [
'program' => $project->getEntryPoint()->getAbsolutePath(),
SandboxedCliRunner::ROOT_ARGUMENT => $project->getBaseDirectory(),
]),
);
} finally {
(new Filesystem())->remove($project->getBaseDirectory());
}

$data = [
'results' => $this->resultsRenderer->render($results, $exercise),
Expand All @@ -66,9 +74,6 @@ public function __invoke(
);
}

//clean up
(new Filesystem())->remove($project->getBaseDirectory());

return $this->withJson($data, $response);
}

Expand Down
19 changes: 18 additions & 1 deletion src/Online/CloudInstalledWorkshop.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
use PhpSchool\PhpWorkshop\Exercise\ExerciseInterface;
use PhpSchool\PhpWorkshop\ExerciseDispatcher;
use PhpSchool\PhpWorkshop\ExerciseRepository;
use PhpSchool\PhpWorkshop\ExerciseRunner\Factory\CustomVerifyingRunnerFactory;
use PhpSchool\PhpWorkshop\ExerciseRunner\RunnerManager;
use PhpSchool\PhpWorkshop\Listener\InitialCodeListener;
use PhpSchool\PhpWorkshop\Listener\OutputRunInfoListener;
use PhpSchool\PhpWorkshop\Logger\Logger;
use PhpSchool\PhpWorkshop\Output\BufferedOutput;
use PhpSchool\PhpWorkshop\Output\OutputInterface;
use PhpSchool\Website\Entity\Workshop;
use PhpSchool\Website\Online\Sandbox\PhpSandbox;
use PhpSchool\Website\Online\Sandbox\SandboxedRunnerFactory;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\String\Slugger\AsciiSlugger;
Expand All @@ -25,7 +29,7 @@ class CloudInstalledWorkshop implements \JsonSerializable
private Workshop $workshop;
private AsciiSlugger $slugger;

public function __construct(Application $application, Workshop $workshop)
public function __construct(Application $application, Workshop $workshop, PhpSandbox $sandbox = new PhpSandbox())
{
/** @var \DI\Container $container */
$container = $application->configure();
Expand All @@ -34,6 +38,7 @@ public function __construct(Application $application, Workshop $workshop)
$this->slugger = new AsciiSlugger();

$this->configureLogger();
$this->configureRunners($sandbox);
$this->configureEvents();
}

Expand Down Expand Up @@ -124,6 +129,18 @@ private function configureLogger(): void
$this->container->set(OutputInterface::class, new BufferedOutput());
}

private function configureRunners(PhpSandbox $sandbox): void
{
/** @var EventDispatcher $eventDispatcher */
$eventDispatcher = $this->container->get(EventDispatcher::class);

$manager = new RunnerManager();
$manager->addFactory(new SandboxedRunnerFactory($eventDispatcher, $sandbox));
$manager->addFactory(new CustomVerifyingRunnerFactory());

$this->container->set(RunnerManager::class, $manager);
}

private function configureEvents(): void
{
/** @var EventDispatcher $eventDispatch */
Expand Down
72 changes: 72 additions & 0 deletions src/Online/ComposerPackageAllowlist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace PhpSchool\Website\Online;

class ComposerPackageAllowlist
{
public const DEFAULT_PACKAGE_LIST = __DIR__ . '/../../var/packages.json';

private const NAME_PATTERN = '#^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$#';
private const VERSION_PATTERN = '#^[a-zA-Z0-9.*^~<>=!|,@ -]{1,64}$#';

/**
* @var array<string, int>|null
*/
private ?array $packages = null;

public function __construct(
private readonly string $packageListPath = self::DEFAULT_PACKAGE_LIST,
private readonly int $maxPackages = 10,
) {}

/**
* @return array<string, string>
*/
public function validate(mixed $dependencies): array
{
if (!is_array($dependencies)) {
throw new \RuntimeException('Invalid composer dependencies');
}

if (count($dependencies) > $this->maxPackages) {
throw new \RuntimeException(sprintf('A maximum of %d composer dependencies are allowed', $this->maxPackages));
}

$require = [];
foreach ($dependencies as $dependency) {
if (!is_array($dependency) || !is_string($dependency['name'] ?? null) || !is_string($dependency['version'] ?? null)) {
throw new \RuntimeException('Invalid composer dependencies');
}

$name = $dependency['name'];
$version = $dependency['version'];

if (1 !== preg_match(self::NAME_PATTERN, $name) || !$this->isKnownPackage($name)) {
throw new \RuntimeException(sprintf('Composer package "%s" is not allowed', $name));
}

if (1 !== preg_match(self::VERSION_PATTERN, $version)) {
throw new \RuntimeException(sprintf('Invalid version constraint for composer package "%s"', $name));
}

$require[$name] = $version;
}

return $require;
}

private function isKnownPackage(string $name): bool
{
if ($this->packages === null) {
$contents = is_readable($this->packageListPath) ? file_get_contents($this->packageListPath) : false;
$list = $contents !== false ? json_decode($contents, true) : null;
$names = is_array($list) && is_array($list['packageNames'] ?? null) ? $list['packageNames'] : [];

$this->packages = array_flip(array_filter($names, 'is_string'));
}

return isset($this->packages[$name]);
}
}
78 changes: 62 additions & 16 deletions src/Online/ProjectUploader.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@
use PhpSchool\Website\User\StudentDTO;
use Psr\Http\Message\ServerRequestInterface as Request;
use Symfony\Component\Filesystem\Filesystem;
use PhpSchool\Website\Online\Sandbox\PhpSandbox;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;

class ProjectUploader
{
public function __construct(private readonly PathGenerator $pathGenerator) {}
public function __construct(
private readonly PathGenerator $pathGenerator,
private readonly ComposerPackageAllowlist $composerPackageAllowlist = new ComposerPackageAllowlist(),
private readonly PhpSandbox $sandbox = new PhpSandbox(),
) {}

public function upload(Request $request, StudentDTO $student): DirectorySolution
{
Expand All @@ -23,46 +29,86 @@ public function upload(Request $request, StudentDTO $student): DirectorySolution
throw new \RuntimeException('No files were uploaded');
}

$require = [];
if (!empty($data['composer_deps'])) {
$require = $this->composerPackageAllowlist->validate($data['composer_deps']);
}

$basePath = $this->pathGenerator->random($student);

if (file_exists($basePath)) {
throw new \RuntimeException('Temporary directory already exists');
}

try {
$this->writeScripts($data['scripts'], $basePath);

if (count($require) > 0) {
$this->installDependencies($require, $basePath);
}
} catch (\RuntimeException $e) {
(new Filesystem())->remove($basePath);
throw $e;
}

if (isset($data['composer_deps']) && count($data['composer_deps']) > 0) {
$composer = [
'require' => array_combine(
array_column($data['composer_deps'], 'name'),
array_column($data['composer_deps'], 'version'),
)
];
$entryPoint = 'solution.php';
if (isset($data['entry_point']) && is_string($data['entry_point']) && isset($data['scripts'][$data['entry_point']])) {
$entryPoint = $data['entry_point'];
}

file_put_contents(Path::join($basePath, 'composer.json'), json_encode($composer));
return new DirectorySolution($basePath, $entryPoint, ['composer.lock', 'vendor']);
}

$process = (new Process(['composer', 'update', '--no-interaction'], $basePath, ['XDEBUG_MODE' => 'off']));
$process->run();
/**
* @param array<string, string> $require
*/
private function installDependencies(array $require, string $basePath): void
{
$composer = [
'require' => $require,
'config' => ['allow-plugins' => false],
];

file_put_contents(Path::join($basePath, 'composer.json'), json_encode($composer));

$env = ['XDEBUG_MODE' => 'off', 'COMPOSER_NO_INTERACTION' => '1'];
foreach (['PATH', 'HOME', 'COMPOSER_HOME', 'COMPOSER_CACHE_DIR'] as $name) {
$value = getenv($name);
if (is_string($value) && $value !== '') {
$env[$name] = $value;
}
}

$entryPoint = 'solution.php';
if (isset($data['entry_point']) && isset($data['scripts'][$data['entry_point']])) {
$entryPoint = $data['entry_point'];
$process = new Process(
['composer', 'update', '--no-interaction', '--no-plugins', '--no-scripts', '--no-progress'],
$basePath,
$this->sandbox->environment($env),
null,
120
);

try {
$process->run();
} catch (ProcessTimedOutException) {
throw new \RuntimeException('Composer dependencies could not be installed');
}

return new DirectorySolution($basePath, $entryPoint, ['composer.lock', 'vendor']);
if (!$process->isSuccessful()) {
throw new \RuntimeException('Composer dependencies could not be installed');
}
}

/**
* @param array<string, string> $scripts
* @param array<mixed> $scripts
*/
private function writeScripts(array $scripts, string $basePath): void
{
foreach ($scripts as $filePath => $content) {
if (!is_string($content)) {
throw new \RuntimeException('Invalid file contents');
}

$filePath = (string) $filePath;
$this->validatePath($filePath);

$fileName = basename($filePath);
Expand Down
Loading
Loading