diff --git a/doc/manual/config-advanced.rst b/doc/manual/config-advanced.rst index 52c9dcd270f..72b8016740c 100644 --- a/doc/manual/config-advanced.rst +++ b/doc/manual/config-advanced.rst @@ -7,7 +7,7 @@ DOMjudge can optionally present country flags, affiliation logos, team pictures and a page-wide banner on the public interface. You can place the images under the path `public/images/` (see -the Config checker in the admin interfae for the full filesystem +the Config checker in the admin interface for the full filesystem path of your installation) as follows: - *Country flags* are shown when the ``show_flags`` configuration option @@ -405,7 +405,7 @@ Clearing the PHP/Symfony cache ------------------------------ Some operations require you to clear the PHP/Symfony cache. To do this, execute -the `webapp/bin/console` (see the Config checker in the admin interfae for the +the `webapp/bin/console` (see the Config checker in the admin interface for the full filesystem path of your installation) binary with the `cache:clear` subcommand:: webapp/bin/console cache:clear diff --git a/docker-compose.yml b/docker-compose.yml index 7d53b7f91a5..b3a7333efe5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ # Note: this docker compose stack should only be used for development purposes. # Do not use it for production deployments. If you want to deploy DOMjudge in # production with Docker, see https://hub.docker.com/r/domjudge/domserver. +# It is recommended to use `docker compose up` to start this stack. Note, don't +# use sudo or the legacy docker-compose. version: '3' diff --git a/etc/db-config.yaml b/etc/db-config.yaml index d5f431f7649..f74df89c527 100644 --- a/etc/db-config.yaml +++ b/etc/db-config.yaml @@ -269,6 +269,15 @@ default_value: false public: true description: Show canonical compiler and runner version on the team pages. + - name: show_teams_on_scoreboard + type: int + default_value: 0 + public: true + description: Show teams on the scoreboard? + options: + 0: Always + 1: After login + 2: After first submission - category: Authentication description: Options related to authentication. items: diff --git a/judge/create_cgroups.in b/judge/create_cgroups.in index 1f9471d2844..fbfe48898be 100755 --- a/judge/create_cgroups.in +++ b/judge/create_cgroups.in @@ -9,8 +9,8 @@ JUDGEHOSTUSER=@DOMJUDGE_USER@ CGROUPBASE=@judgehost_cgroupdir@ -print_cgroup_instruction () { - echo "" +cgroup_error_and_usage () { + echo "$1" >&2 echo "To fix this, please make the following changes: 1. In /etc/default/grub, add 'cgroup_enable=memory swapaccount=1' to GRUB_CMDLINE_LINUX_DEFAULT. On modern distros (e.g. Debian bullseye and Ubuntu Jammy Jellyfish) which have cgroup v2 enabled by default, @@ -24,16 +24,14 @@ for i in cpuset memory; do mkdir -p $CGROUPBASE/$i if [ ! -d $CGROUPBASE/$i/ ]; then if ! mount -t cgroup -o$i $i $CGROUPBASE/$i/; then - echo "Error: Can not mount $i cgroup. Probably cgroup support is missing from running kernel. Unable to continue." - print_cgroup_instruction + cgroup_error_and_usage "Error: Can not mount $i cgroup. Probably cgroup support is missing from running kernel. Unable to continue." fi fi mkdir -p $CGROUPBASE/$i/domjudge done if [ ! -f $CGROUPBASE/memory/memory.limit_in_bytes ] || [ ! -f $CGROUPBASE/memory/memory.memsw.limit_in_bytes ]; then - echo "Error: cgroup support missing memory features in running kernel. Unable to continue." - print_cgroup_instruction + cgroup_error_and_usage "Error: cgroup support missing memory features in running kernel. Unable to continue." fi chown -R $JUDGEHOSTUSER $CGROUPBASE/*/domjudge diff --git a/judge/judgedaemon.main.php b/judge/judgedaemon.main.php index 367b92b4cd9..bb3f9eeef12 100644 --- a/judge/judgedaemon.main.php +++ b/judge/judgedaemon.main.php @@ -327,6 +327,7 @@ function fetch_executable_internal( $execid, $hash ]); + global $langexts; $execdeploypath = $execdir . '/.deployed'; $execbuilddir = $execdir . '/build'; $execbuildpath = $execbuilddir . '/build'; @@ -379,12 +380,6 @@ function fetch_executable_internal( $do_compile = false; } else { // detect lang and write build file - $langexts = [ - 'c' => ['c'], - 'cpp' => ['cpp', 'C', 'cc'], - 'java' => ['java'], - 'py' => ['py'], - ]; $buildscript = "#!/bin/sh\n\n"; $execlang = false; $source = ""; @@ -642,6 +637,21 @@ function fetch_executable_internal( // Populate the DOMjudge configuration initially djconfig_refresh(); +// Prepopulate default language extensions, afterwards update based on domserver config +$langexts = [ + 'c' => ['c'], + 'cpp' => ['cpp', 'C', 'cc'], + 'java' => ['java'], + 'py' => ['py'], +]; +$domserver_languages = dj_json_decode(request('languages', 'GET')); +foreach ($domserver_languages as $language) { + $id = $language['id']; + if (key_exists($id, $langexts)) { + $langexts[$id] = $language['extensions']; + } +} + // Constantly check API for unjudged submissions $endpointIDs = array_keys($endpoints); $currentEndpoint = 0; @@ -1069,7 +1079,7 @@ function compile( ): bool { global $myhost, $EXITCODES; - // Re-use compilation if it already exists. + // Reuse compilation if it already exists. if (file_exists("$workdir/compile.success")) { return true; } @@ -1340,10 +1350,6 @@ function judge(array $judgeTask): bool } // do the actual test-run - $hardtimelimit = $run_config['time_limit'] + - overshoot_time($run_config['time_limit'], $overshoot); - - $combined_run_compare = $compare_config['combined_run_compare']; [$run_runpath, $error] = fetch_executable( $workdirpath, @@ -1373,6 +1379,16 @@ function judge(array $judgeTask): bool } } + $hardtimelimit = $run_config['time_limit'] + + overshoot_time($run_config['time_limit'], $overshoot); + if ($combined_run_compare) { + // This accounts for wall time spent in the validator. We may likely + // want to make this configurable in the future. The current factor is + // under the assumption that the validator has to do approximately the + // same amount of work wall-time wise as the submission. + $hardtimelimit *= 2; + } + // While we already set those above to likely the same values from the // compile config, we do set them again from the compare config here. putenv('SCRIPTTIMELIMIT=' . $compare_config['script_timelimit']); diff --git a/judge/runguard.c b/judge/runguard.c index a1097b8b7b1..94e3dc2215c 100644 --- a/judge/runguard.c +++ b/judge/runguard.c @@ -799,7 +799,7 @@ void setrestrictions() setlim(STACK); if ( filesize!=RLIM_INFINITY ) { - verbose("setting filesize limit to %d bytes",(int)filesize); + verbose("setting filesize limit to %lu bytes",filesize); lim.rlim_cur = lim.rlim_max = filesize; setlim(FSIZE); } @@ -1428,8 +1428,23 @@ int main(int argc, char **argv) } else { exitcode = WEXITSTATUS(status); } + verbose("child exited with exit code %d", exitcode); - check_remaining_procs(); + if ( use_walltime ) { + /* Disarm timer we set previously so if any of the + * clean-up steps below are slow we are not mistaking + * this for a wall-time timeout. */ + itimer.it_interval.tv_sec = 0; + itimer.it_interval.tv_usec = 0; + itimer.it_value.tv_sec = 0; + itimer.it_value.tv_usec = 0; + + if ( setitimer(ITIMER_REAL,&itimer,NULL)!=0 ) { + error(errno,"disarming timer"); + } + } + + check_remaining_procs(); double cputime = -1; output_cgroup_stats(&cputime); diff --git a/misc-tools/configure-domjudge.in b/misc-tools/configure-domjudge.in index 360f783d5e1..9f649686e84 100755 --- a/misc-tools/configure-domjudge.in +++ b/misc-tools/configure-domjudge.in @@ -17,6 +17,7 @@ import json import os.path import requests import requests.utils +import shutil import sys from typing import List, Set @@ -98,16 +99,6 @@ else: print('Need a "config.json" to update general DOMjudge configuration.') -if os.path.exists('executables'): - executables = [] - for file in os.listdir('executables'): - if file.endswith(".zip"): - executables.append(file[:-4]) - if executables and dj_utils.confirm('Upload language executables (found: ' + ','.join(executables) + ')?', False): - for langid in executables: - dj_utils.upload_file(f'languages/{langid}/executable', 'executable', f'executables/{langid}.zip') - - if os.path.exists('languages.json'): print('Comparing DOMjudge\'s language configuration:') with open('languages.json') as langConfigFile: @@ -126,7 +117,29 @@ if os.path.exists('languages.json'): if missing_keys: print(f' - missing keys from expected config = {missing_keys}') if diffs or new_keys or missing_keys: - print(' - We cannot update the configuration yet, but you might want to change it via the UI.') + if os.path.exists('executables'): + executables = [] + for file in os.listdir('executables'): + if os.path.isdir(f'executables/{file}'): + executables.append(file) + shutil.make_archive(f'executables/{file}', 'zip', f'executables/{file}') + + if executables: + if dj_utils.confirm('Upload language executables (found: ' + ','.join(executables) + ')?', False): + for langid in executables: + dj_utils.upload_file(f'languages/{langid}/executable', 'executable', f'executables/{langid}.zip') + for langid in executables: + os.remove(f'executables/{langid}.zip') + if dj_utils.confirm(' - Upload configuration changes?', True): + actual_config = _keyify_list(dj_utils.upload_file(f'languages', 'json', f'languages.json')) + diffs, new_keys, missing_keys = compare_configs( + actual_config=actual_config, + expected_config=expected_config + ) + if diffs: + print(' - There are still configuration differences after uploading:') + for d in diffs: + print(d) else: print(' - Language configuration is already up-to-date, nothing to update.') else: diff --git a/misc-tools/import-contest.in b/misc-tools/import-contest.in index fe7a1bf63b2..1f6226bbad3 100755 --- a/misc-tools/import-contest.in +++ b/misc-tools/import-contest.in @@ -68,7 +68,10 @@ def import_images(entity: str, property: str, filename_regexes: List[str]): if not os.path.isdir(entity): return images_per_entity = {} - for entity_id in listdir(entity): + with open(f'{entity}.json') as entityFile: + entities = json.load(entityFile) + entity_ids = [entity['id'] for entity in entities] + for entity_id in entity_ids: entity_dir = f'{entity}/{entity_id}' if not os.path.isdir(entity_dir): continue @@ -154,9 +157,10 @@ if cid is not None: # Problem import is also special: we need to upload each individual problem and detect what they are if os.path.exists('problems.yaml') or os.path.exists('problems.json') or os.path.exists('problemset.yaml'): if dj_utils.confirm('Import problems?', True): - # Check if our user is linked to a team + # Check if our user is linked to a team. user_data = dj_utils.do_api_request('user') - if not 'team' in user_data and not dj_utils.confirm('No team associated with your account. Jury submissions won\'t be imported. Really continue?', False): + has_team_linked = 'team' in user_data and user_data['team'] and 'roles' in user_data and 'team' in user_data['roles'] + if not has_team_linked and not dj_utils.confirm('No team associated with your account. Jury submissions won\'t be imported. Really continue?', False): exit(2) print('Importing problems.') diff --git a/sql/files/defaultdata/kt/run b/sql/files/defaultdata/kt/run index a2447c58f54..e0770991058 100755 --- a/sql/files/defaultdata/kt/run +++ b/sql/files/defaultdata/kt/run @@ -19,7 +19,7 @@ COMPILESCRIPTDIR="$(dirname "$0")" # Note that you then also might want to fix this in the compiler and runner version commands. # For example # KOTLIN_DIR=/usr/lib/kotlinc/bin -KOTLIN_DIR="$(dirname "$(command -v kotlinc)")" +KOTLIN_DIR="$(dirname "$(realpath "$(command -v kotlinc)")")" # Stack size in the JVM in KB. Note that this will be deducted from # the total memory made available for the heap. diff --git a/webapp/migrations/Version20231006185028.php b/webapp/migrations/Version20231006185028.php new file mode 100644 index 00000000000..a31c26b8574 --- /dev/null +++ b/webapp/migrations/Version20231006185028.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE external_contest_source ADD last_httpcode SMALLINT UNSIGNED DEFAULT NULL COMMENT \'Last HTTP code received by event feed reader\''); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE external_contest_source DROP last_httpcode'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/public/js/domjudge.js b/webapp/public/js/domjudge.js index bfc2de7a9b8..bd1b254320d 100644 --- a/webapp/public/js/domjudge.js +++ b/webapp/public/js/domjudge.js @@ -85,7 +85,7 @@ function sendNotification(title, options = {}) if ( getCookie('domjudge_notify')==1 ) { var not = new Notification(title, options); if ( link!==null ) { - not.onclick = function() { + not.onclick = function() { window.location.href = link; }; } @@ -435,10 +435,14 @@ function processAjaxResponse(jqXHR, data) { window.location = jqXHR.getResponseHeader('X-Login-Page'); } else { var newCurrentContest = jqXHR.getResponseHeader('X-Current-Contest'); - var dataCurrentContest = $('[data-current-contest]').data('current-contest').toString(); - - // If the contest ID changed from another tab, reload or whole page - if (dataCurrentContest !== undefined && newCurrentContest !== dataCurrentContest) { + var dataCurrentContest = $('[data-current-contest]').data('current-contest'); + var refreshStop = $('[data-ajax-refresh-stop]').data('ajax-refresh-stop'); + + // Reload the whole page if + // - we were signaled to stop refreshing, or + // - if the contest ID changed from another tab. + if ((refreshStop !== undefined && refreshStop.toString() === "1") + || (dataCurrentContest !== undefined && newCurrentContest !== dataCurrentContest.toString())) { window.location.reload(); return; } diff --git a/webapp/public/style_domjudge.css b/webapp/public/style_domjudge.css index 8884a7441e6..22e03c303df 100644 --- a/webapp/public/style_domjudge.css +++ b/webapp/public/style_domjudge.css @@ -630,3 +630,8 @@ blockquote { padding-left: .5em; color: darkgrey; } + +.category-best { + margin-right: 2em; + font-weight: normal; +} diff --git a/webapp/public/style_jury.css b/webapp/public/style_jury.css index 6066c1c9d27..30916c56420 100644 --- a/webapp/public/style_jury.css +++ b/webapp/public/style_jury.css @@ -234,3 +234,7 @@ table.table-full-clickable-cell thead.thead-light tr th.table-button-head-left { table.table-full-clickable-cell tr .table-button-head-right-right{ padding-left: 0.5rem; } + +.execid { + font-family: monospace; +} \ No newline at end of file diff --git a/webapp/src/Controller/API/LanguageController.php b/webapp/src/Controller/API/LanguageController.php index 0facdfe48de..3128113d5c9 100644 --- a/webapp/src/Controller/API/LanguageController.php +++ b/webapp/src/Controller/API/LanguageController.php @@ -10,6 +10,7 @@ use FOS\RestBundle\Controller\Annotations as Rest; use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -122,6 +123,89 @@ public function updateExecutableActions(Request $request, string $id): void $this->dj->auditlog('executable', $language->getLangid(), 'updated'); } + #[IsGranted('ROLE_ADMIN')] + #[Rest\Post('languages')] + #[OA\Response( + response: 200, + description: 'Configure all specified languages.', + )] + public function configureLanguagesAction(Request $request): Response + { + /** @var UploadedFile|null $jsonFile */ + $jsonFile = $request->files->get('json'); + if (!$jsonFile) { + throw new BadRequestHttpException('No JSON file supplied.'); + } + $newLanguages = $this->dj->jsonDecode(file_get_contents($jsonFile->getRealPath())); + + // Disable submission for all current languages, we will enable it for all new languages below. + $curLanguages = $this->em->getRepository(Language::class)->findAll(); + foreach ($curLanguages as $language) { + /** @var Language $language */ + $language->setAllowSubmit(false); + } + + $idField = $this->eventLogService->externalIdFieldForEntity(Language::class) ?? 'langid'; + foreach ($newLanguages as $language) { + /** @var Language $language */ + $lang_id = $language['id']; + $lang = $this->em->getRepository(Language::class)->findOneBy( + [$idField => $lang_id] + ); + if (!$lang) { + // TODO: Decide how to handle this case, either erroring out or creating a new language. + continue; + } + + // We disallowed submission for all languages above, so we need to enable it for the given languages. + $lang->setAllowSubmit(true); + + if (isset($language['name'])) { + $lang->setName($language['name']); + } + if (isset($language['allow_submit'])) { + $lang->setAllowSubmit($language['allow_submit']); + } + if (isset($language['allow_judge'])) { + $lang->setAllowJudge($language['allow_judge']); + } + if (isset($language['entry_point_required'])) { + $lang->setRequireEntryPoint($language['entry_point_required']); + } + if (isset($language['entry_point_name'])) { + $lang->setEntryPointDescription($language['entry_point_name']); + } + if (isset($language['extensions'])) { + $lang->setExtensions($language['extensions']); + } + if (isset($language['filter_compiler_files'])) { + $lang->setFilterCompilerFiles($language['filter_compiler_files']); + } + if (isset($language['time_factor'])) { + $lang->setTimeFactor($language['time_factor']); + } + if (isset($language['compiler'])) { + if (isset($language['compiler']['version_command'])) { + $lang->setCompilerVersionCommand($language['compiler']['version_command']); + } + if (isset($language['compiler']['version'])) { + $lang->setCompilerVersion($language['compiler']['version']); + } + } + if (isset($language['runner'])) { + if (isset($language['runner']['version_command'])) { + $lang->setRunnerVersionCommand($language['runner']['version_command']); + } + if (isset($language['runner']['version'])) { + $lang->setRunnerVersion($language['runner']['version']); + } + } + } + $this->em->flush(); + + return parent::performListAction($request); + } + protected function getQueryBuilder(Request $request): QueryBuilder { if ($request->attributes->has('cid')) { diff --git a/webapp/src/Controller/API/OrganizationController.php b/webapp/src/Controller/API/OrganizationController.php index 0584351f798..0d2ef6b8bf0 100644 --- a/webapp/src/Controller/API/OrganizationController.php +++ b/webapp/src/Controller/API/OrganizationController.php @@ -100,7 +100,7 @@ public function singleAction(Request $request, string $id): Response * Get the logo for the given organization. */ #[Rest\Get('contests/{cid}/organizations/{id}/logo', name: 'organization_logo')] - #[Rest\Get('organizations/{id}/logo')] + #[Rest\Get('organizations/{id}/logo', name: 'no_contest_organization_logo')] #[OA\Response( response: 200, description: 'Returns the given organization logo in PNG, JPG or SVG format', diff --git a/webapp/src/Controller/API/TeamController.php b/webapp/src/Controller/API/TeamController.php index e32ab1ca7a5..dbc8ec7fdd9 100644 --- a/webapp/src/Controller/API/TeamController.php +++ b/webapp/src/Controller/API/TeamController.php @@ -113,7 +113,7 @@ public function singleAction(Request $request, string $id): Response * Get the photo for the given team. */ #[Rest\Get('contests/{cid}/teams/{id}/photo', name: 'team_photo')] - #[Rest\Get('teams/{id}/photo')] + #[Rest\Get('teams/{id}/photo', name: 'no_contest_team_photo')] #[OA\Response( response: 200, description: 'Returns the given team photo in PNG, JPG or SVG format', diff --git a/webapp/src/Controller/Jury/ExecutableController.php b/webapp/src/Controller/Jury/ExecutableController.php index 26c0b10ef7e..ca81cbd36b5 100644 --- a/webapp/src/Controller/Jury/ExecutableController.php +++ b/webapp/src/Controller/Jury/ExecutableController.php @@ -3,6 +3,7 @@ namespace App\Controller\Jury; use App\Controller\BaseController; +use App\Entity\ContestProblem; use App\Entity\Executable; use App\Entity\ExecutableFile; use App\Entity\ImmutableExecutable; @@ -12,6 +13,7 @@ use App\Service\EventLogService; use App\Utils\Utils; use Doctrine\ORM\EntityManagerInterface; +use InvalidArgumentException as PHPInvalidArgumentException; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Form\Exception\InvalidArgumentException; @@ -42,6 +44,8 @@ public function __construct( #[Route(path: '', name: 'jury_executables')] public function indexAction(Request $request): Response { + $executables_tables_used = []; + $executables_tables_unused = []; $data = []; $form = $this->createForm(ExecutableUploadType::class, $data); $form->handleRequest($request); @@ -56,14 +60,64 @@ public function indexAction(Request $request): Response ->getQuery()->getResult(); $executables = array_column($executables, 'executable', 'execid'); $table_fields = [ + 'icon' => ['title' => 'type', 'sort' => false], 'execid' => ['title' => 'ID', 'sort' => true,], 'type' => ['title' => 'type', 'sort' => true,], + 'badges' => ['title' => 'problems', 'sort' => false], 'description' => ['title' => 'description', 'sort' => true,], ]; $propertyAccessor = PropertyAccess::createPropertyAccessor(); $executables_table = []; + $configScripts = []; + foreach (['compare', 'run', 'full_debug'] as $config_script) { + try { + $configScripts[] = (string)$this->config->get('default_' . $config_script); + } catch (PHPInvalidArgumentException $e) { + // If not found this is an older database, as we only use this for visual changes ignore this error; + } + } + + $contestProblemsWithExecutables = []; + $executablesWithContestProblems = []; + if ($this->dj->getCurrentContest()) { + $contestProblemsWithExecutables = $em->createQueryBuilder() + ->select('cp', 'p', 'ecomp') + ->from(ContestProblem::class, 'cp') + ->where('cp.contest = :contest') + ->setParameter('contest', $this->dj->getCurrentContest()) + ->join('cp.problem', 'p') + ->leftJoin('p.compare_executable', 'ecomp') + ->leftJoin('p.run_executable', 'erun') + ->andWhere('ecomp IS NOT NULL OR erun IS NOT NULL') + ->getQuery()->getResult(); + $executablesWithContestProblems = $em->createQueryBuilder() + ->select('e') + ->from(Executable::class, 'e') + ->leftJoin('e.problems_compare', 'pcomp') + ->leftJoin('e.problems_run', 'prun') + ->where('pcomp IS NOT NULL OR prun IS NOT NULL') + ->leftJoin('pcomp.contest_problems', 'cpcomp') + ->leftJoin('prun.contest_problems', 'cprun') + ->andWhere('cprun.contest = :contest OR cpcomp.contest = :contest') + ->setParameter('contest', $this->dj->getCurrentContest()) + ->getQuery()->getResult(); + } + foreach ($executables as $e) { + $badges = []; + if (in_array($e, $executablesWithContestProblems)) { + foreach (array_merge($e->getProblemsRun()->toArray(), $e->getProblemsCompare()->toArray()) as $execProblem) { + $execContestProblems = $execProblem->getContestProblems(); + foreach($contestProblemsWithExecutables as $cp) { + if ($execContestProblems->contains($cp)) { + $badges[] = $cp; + } + } + } + } + sort($badges); + $execdata = []; $execactions = []; // Get whatever fields we can from the team object itself. @@ -72,6 +126,25 @@ public function indexAction(Request $request): Response $execdata[$k] = ['value' => $propertyAccessor->getValue($e, $k)]; } } + $execdata['execid']['cssclass'] = 'execid'; + $type = $execdata['type']['value']; + switch ($type) { + case 'compare': + $execdata['icon']['icon'] = 'code-compare'; + break; + case 'compile': + $execdata['icon']['icon'] = 'language'; + break; + case 'debug': + $execdata['icon']['icon'] = 'bug'; + break; + case 'run': + $execdata['icon']['icon'] = 'person-running'; + break; + default: + $execdata['icon']['icon'] = 'question'; + } + $execdata['badges']['value'] = $badges; if ($this->isGranted('ROLE_ADMIN')) { $execactions[] = [ @@ -96,14 +169,27 @@ public function indexAction(Request $request): Response 'link' => $this->generateUrl('jury_executable_download', ['execId' => $e->getExecid()]) ]; - $executables_table[] = [ - 'data' => $execdata, - 'actions' => $execactions, - 'link' => $this->generateUrl('jury_executable', ['execId' => $e->getExecid()]), - ]; + if ($e->checkUsed($configScripts)) { + $executables_tables_used[] = [ + 'data' => $execdata, + 'actions' => $execactions, + 'link' => $this->generateUrl('jury_executable', ['execId' => $e->getExecid()]), + ]; + } else { + $executables_tables_unused[] = [ + 'data' => $execdata, + 'actions' => $execactions, + 'link' => $this->generateUrl('jury_executable', ['execId' => $e->getExecid()]), + 'cssclass' => 'disabled', + ]; + } } + // This is replaced with the icon. + unset($table_fields['type']); + return $this->render('jury/executables.html.twig', [ - 'executables' => $executables_table, + 'executables_used' => $executables_tables_used, + 'executables_unused' => $executables_tables_unused, 'table_fields' => $table_fields, 'form' => $form, ]); diff --git a/webapp/src/Controller/Jury/SubmissionController.php b/webapp/src/Controller/Jury/SubmissionController.php index b99110d4340..406440daffe 100644 --- a/webapp/src/Controller/Jury/SubmissionController.php +++ b/webapp/src/Controller/Jury/SubmissionController.php @@ -72,7 +72,7 @@ public function indexAction( #[MapQueryParameter(name: 'view')] ?string $viewFromRequest = null, ): Response { - $viewTypes = [0 => 'newest', 1 => 'unverified', 2 => 'unjudged', 3 => 'all']; + $viewTypes = [0 => 'newest', 1 => 'unverified', 2 => 'unjudged', 3 => 'judging', 4 => 'all']; $view = 0; if (($submissionViewCookie = $this->dj->getCookie('domjudge_submissionview')) && isset($viewTypes[$submissionViewCookie])) { @@ -101,6 +101,9 @@ public function indexAction( if ($viewTypes[$view] == 'unjudged') { $restrictions['judged'] = 0; } + if ($viewTypes[$view] == 'judging') { + $restrictions['judging'] = 1; + } $contests = $this->dj->getCurrentContests(); if ($contest = $this->dj->getCurrentContest()) { @@ -230,10 +233,12 @@ public function viewAction( ->from(JudgeTask::class, 'jt', 'jt.jobid') ->select('jt') ->andWhere('jt.jobid IN (:jobIds)') + ->andWhere('jt.type = :type') ->setParameter( 'jobIds', array_map(static fn(Judging $judging) => $judging->getJudgingid(), $judgings) ) + ->setParameter('type', JudgeTaskType::JUDGING_RUN) ->getQuery() ->getResult(); $timelimits = array_map(function (JudgeTask $task) { diff --git a/webapp/src/DataFixtures/DefaultData/TeamCategoryFixture.php b/webapp/src/DataFixtures/DefaultData/TeamCategoryFixture.php index 106452f3394..630ba2f216e 100644 --- a/webapp/src/DataFixtures/DefaultData/TeamCategoryFixture.php +++ b/webapp/src/DataFixtures/DefaultData/TeamCategoryFixture.php @@ -23,7 +23,7 @@ public function load(ObjectManager $manager): void ]; foreach ($data as $item) { - if (!($category = $manager->getRepository(TeamCategory::class)->findOneBy(['name' => $item[0]]))) { + if (!($category = $manager->getRepository(TeamCategory::class)->findOneBy(['externalid' => $item[4]]))) { $category = (new TeamCategory()) ->setName($item[0]) ->setSortorder($item[1]) diff --git a/webapp/src/DataFixtures/Test/RejudgingFirstToSolveFixture.php b/webapp/src/DataFixtures/Test/RejudgingFirstToSolveFixture.php new file mode 100644 index 00000000000..32132653e8b --- /dev/null +++ b/webapp/src/DataFixtures/Test/RejudgingFirstToSolveFixture.php @@ -0,0 +1,56 @@ +getRepository(Team::class)->findOneBy(['name' => 'Example teamname']); + $team2 = (new Team()) + ->setName('Another team') + ->setCategory($team1->getCategory()); + + $manager->persist($team2); + + $submissionData = [ + // team, submittime, result] + [$team1, '2021-01-01 12:34:56', 'correct'], + [$team2, '2021-01-01 12:33:56', 'wrong-answer'], + ]; + + $contest = $manager->getRepository(Contest::class)->findOneBy(['shortname' => 'demo']); + $language = $manager->getRepository(Language::class)->find('cpp'); + $problem = $contest->getProblems()->filter(fn(ContestProblem $problem) => $problem->getShortname() === 'A')->first(); + + foreach ($submissionData as $index => $submissionItem) { + $submission = (new Submission()) + ->setContest($contest) + ->setTeam($submissionItem[0]) + ->setContestProblem($problem) + ->setLanguage($language) + ->setValid(true) + ->setSubmittime(Utils::toEpochFloat($submissionItem[1])); + $judging = (new Judging()) + ->setContest($contest) + ->setStarttime(Utils::toEpochFloat($submissionItem[1])) + ->setEndtime(Utils::toEpochFloat($submissionItem[1]) + 5) + ->setValid(true) + ->setResult($submissionItem[2]); + $judging->setSubmission($submission); + $submission->addJudging($judging); + $manager->persist($submission); + $manager->persist($judging); + $manager->flush(); + } + } +} diff --git a/webapp/src/Entity/Executable.php b/webapp/src/Entity/Executable.php index 30eb1aa4224..3203d230045 100644 --- a/webapp/src/Entity/Executable.php +++ b/webapp/src/Entity/Executable.php @@ -181,4 +181,25 @@ public function getZipfileContent(string $tempdir): string unlink($tempzipFile); return $zipFileContents; } + + /** + * @param string[] $configScripts + */ + public function checkUsed(array $configScripts): bool + { + foreach ($configScripts as $config_script) { + if ($this->execid === $config_script) { + return true; + } + } + if (count($this->problems_compare) || count($this->problems_run)) { + return true; + } + foreach ($this->languages as $lang) { + if ($lang->getAllowSubmit()) { + return true; + } + } + return false; + } } diff --git a/webapp/src/Entity/ExternalContestSource.php b/webapp/src/Entity/ExternalContestSource.php index 86a1951422c..5f04102e679 100644 --- a/webapp/src/Entity/ExternalContestSource.php +++ b/webapp/src/Entity/ExternalContestSource.php @@ -49,6 +49,13 @@ class ExternalContestSource )] private ?float $lastPollTime = null; + #[ORM\Column( + type: 'smallint', + nullable: true, + options: ['comment' => 'Last HTTP code received by event feed reader', 'unsigned' => true] + )] + public ?int $lastHTTPCode = null; + #[ORM\ManyToOne(inversedBy: 'externalContestSources')] #[ORM\JoinColumn(name: 'cid', referencedColumnName: 'cid', onDelete: 'CASCADE')] private Contest $contest; @@ -176,6 +183,17 @@ public function getShortDescription(): string return $this->getSource(); } + public function setLastHTTPCode(?int $lastHTTPCode): ExternalContestSource + { + $this->lastHTTPCode = $lastHTTPCode; + return $this; + } + + public function getLastHTTPCode(): ?int + { + return $this->lastHTTPCode; + } + #[Assert\Callback] public function validate(ExecutionContextInterface $context): void { diff --git a/webapp/src/Serializer/TeamAffiliationVisitor.php b/webapp/src/Serializer/TeamAffiliationVisitor.php index 39b643d7eb2..b55b0784278 100644 --- a/webapp/src/Serializer/TeamAffiliationVisitor.php +++ b/webapp/src/Serializer/TeamAffiliationVisitor.php @@ -84,13 +84,17 @@ public function onPostSerialize(ObjectEvent $event): void $parts = explode('.', $affiliationLogo); $extension = $parts[count($parts) - 1]; - $route = $this->dj->apiRelativeUrl( - 'v4_organization_logo', - [ - 'cid' => $this->requestStack->getCurrentRequest()->attributes->get('cid'), - 'id' => $id, - ] - ); + if ($cid = $this->requestStack->getCurrentRequest()->attributes->get('cid')) { + $route = $this->dj->apiRelativeUrl( + 'v4_organization_logo', + [ + 'cid' => $cid, + 'id' => $id, + ] + ); + } else { + $route = $this->dj->apiRelativeUrl('v4_no_contest_organization_logo', ['id' => $id]); + } $property = new StaticPropertyMetadata( TeamAffiliation::class, 'logo', diff --git a/webapp/src/Serializer/TeamVisitor.php b/webapp/src/Serializer/TeamVisitor.php index f37319e7513..bb713a3529b 100644 --- a/webapp/src/Serializer/TeamVisitor.php +++ b/webapp/src/Serializer/TeamVisitor.php @@ -62,13 +62,17 @@ public function onPostSerialize(ObjectEvent $event): void $imageSize = Utils::getImageSize($teamPhoto); - $route = $this->dj->apiRelativeUrl( - 'v4_team_photo', - [ - 'cid' => $this->requestStack->getCurrentRequest()->attributes->get('cid'), - 'id' => $id, - ] - ); + if ($cid = $this->requestStack->getCurrentRequest()->attributes->get('cid')) { + $route = $this->dj->apiRelativeUrl( + 'v4_team_photo', + [ + 'cid' => $cid, + 'id' => $id, + ] + ); + } else { + $route = $this->dj->apiRelativeUrl('v4_no_contest_team_photo', ['id' => $id,]); + } $property = new StaticPropertyMetadata( Team::class, 'photo', diff --git a/webapp/src/Service/DOMJudgeService.php b/webapp/src/Service/DOMJudgeService.php index c9da8e411c9..88fcf8d885f 100644 --- a/webapp/src/Service/DOMJudgeService.php +++ b/webapp/src/Service/DOMJudgeService.php @@ -394,7 +394,7 @@ public function getUpdates(): array ->select('ecs.extsourceid', 'ecs.lastPollTime') ->from(ExternalContestSource::class, 'ecs') ->andWhere('ecs.contest = :contest') - ->andWhere('ecs.lastPollTime < :i OR ecs.lastPollTime is NULL') + ->andWhere('ecs.lastPollTime < :i OR ecs.lastPollTime is NULL OR ecs.lastHTTPCode != 200') ->setParameter('contest', $contest) ->setParameter('i', time() - $this->config->get('external_contest_source_critical')) ->getQuery()->getOneOrNullResult(); @@ -654,7 +654,13 @@ public function internalApiRequest(string $url, string $method = Request::METHOD return null; } - return $this->jsonDecode($response->getContent()); + $content = $response->getContent(); + + if ($content === '') { + return null; + } + + return $this->jsonDecode($content); } public function getDomjudgeEtcDir(): string diff --git a/webapp/src/Service/ExternalContestSourceService.php b/webapp/src/Service/ExternalContestSourceService.php index a872472f6fe..ef2683ed832 100644 --- a/webapp/src/Service/ExternalContestSourceService.php +++ b/webapp/src/Service/ExternalContestSourceService.php @@ -308,11 +308,14 @@ protected function importFromCcsApi(array $eventsToSkip, ?callable $progressRepo $fullUrl .= '&since_token=' . $this->getLastReadEventId(); } $response = $this->httpClient->request('GET', $fullUrl, ['buffer' => false]); - if ($response->getStatusCode() !== 200) { + $statusCode = $response->getStatusCode(); + $this->source->setLastHTTPCode($statusCode); + $this->em->flush(); + if ($statusCode !== 200) { $this->logger->warning( 'Received non-200 response code %d, waiting for five seconds ' . 'and trying again. Press ^C to quit.', - [$response->getStatusCode()] + [$statusCode] ); sleep(5); continue; diff --git a/webapp/src/Service/ImportExportService.php b/webapp/src/Service/ImportExportService.php index 4ee8bc1a607..464141a9fbc 100644 --- a/webapp/src/Service/ImportExportService.php +++ b/webapp/src/Service/ImportExportService.php @@ -877,6 +877,15 @@ public function importAccountsJson(array $data, ?string &$message = null, ?array $juryTeam = null; $roles = []; $type = $account['type']; + $username = $account['username']; + + $icpcRegexChars = "[a-zA-Z0-9@._-]"; + $icpcRegex = "/^" . $icpcRegexChars . "+$/"; + if (!preg_match($icpcRegex, $username)) { + $message = sprintf('Username "%s" should be non empty and only contain: %s', $username, $icpcRegexChars); + return -1; + } + // Special case for the World Finals, if the username is CDS we limit the access. // The user can see what every admin can see, but can not log in via the UI. if (isset($account['username']) && $account['username'] === 'cds') { @@ -909,7 +918,7 @@ public function importAccountsJson(array $data, ?string &$message = null, ?array 'user' => [ 'name' => $account['name'] ?? null, 'externalid' => $account['id'] ?? $account['username'], - 'username' => $account['username'], + 'username' => $username, 'plain_password' => $account['password'] ?? null, 'teamid' => $account['team_id'] ?? null, 'user_roles' => $roles, diff --git a/webapp/src/Service/RejudgingService.php b/webapp/src/Service/RejudgingService.php index 12c098d369d..cc1a7db5cc6 100644 --- a/webapp/src/Service/RejudgingService.php +++ b/webapp/src/Service/RejudgingService.php @@ -251,13 +251,10 @@ public function finishRejudging(Rejudging $rejudging, string $action, ?callable ); // Update caches. - $rowIndex = $submission['cid'] . '-' . $submission['teamid'] . '-' . $submission['probid']; - if (!isset($scoreboardRowsToUpdate[$rowIndex])) { - $scoreboardRowsToUpdate[$rowIndex] = [ - 'cid' => $submission['cid'], - 'teamid' => $submission['teamid'], - 'probid' => $submission['probid'], - ]; + $cid = $submission['cid']; + $probid = $submission['probid']; + if (!isset($scoreboardRowsToUpdate[$cid][$probid])) { + $scoreboardRowsToUpdate[$cid][$probid] = true; } // Update event log. @@ -329,16 +326,36 @@ public function finishRejudging(Rejudging $rejudging, string $action, ?callable } // Now update the scoreboard - foreach ($scoreboardRowsToUpdate as $item) { - ['cid' => $cid, 'teamid' => $teamid, 'probid' => $probid] = $item; + foreach ($scoreboardRowsToUpdate as $cid => $probids) { + $probids = array_keys($probids); $contest = $this->em->getRepository(Contest::class)->find($cid); - $team = $this->em->getRepository(Team::class)->find($teamid); - $problem = $this->em->getRepository(Problem::class)->find($probid); - $this->scoreboardService->calculateScoreRow($contest, $team, $problem); + $queryBuilder = $this->em->createQueryBuilder() + ->from(Team::class, 't') + ->select('t') + ->orderBy('t.teamid'); + if (!$contest->isOpenToAllTeams()) { + $queryBuilder + ->leftJoin('t.contests', 'c') + ->join('t.category', 'cat') + ->leftJoin('cat.contests', 'cc') + ->andWhere('c.cid = :cid OR cc.cid = :cid') + ->setParameter('cid', $contest->getCid()); + } + /** @var Team[] $teams */ + $teams = $queryBuilder->getQuery()->getResult(); + foreach ($teams as $team) { + foreach ($probids as $probid) { + $problem = $this->em->getRepository(Problem::class)->find($probid); + $this->scoreboardService->calculateScoreRow($contest, $team, $problem); + } + $this->scoreboardService->updateRankCache($contest, $team); + } } } - $progressReporter(100, $log); + if ($progressReporter !== null) { + $progressReporter(100, $log); + } // Update the rejudging itself. /** @var Rejudging $rejudging */ diff --git a/webapp/src/Service/ScoreboardService.php b/webapp/src/Service/ScoreboardService.php index 5352292e78a..0856f6064ee 100644 --- a/webapp/src/Service/ScoreboardService.php +++ b/webapp/src/Service/ScoreboardService.php @@ -32,6 +32,10 @@ class ScoreboardService { + final public const SHOW_TEAM_ALWAYS = 0; + final public const SHOW_TEAM_AFTER_LOGIN = 1; + final public const SHOW_TEAM_AFTER_SUBMIT = 2; + public function __construct( protected readonly EntityManagerInterface $em, protected readonly DOMJudgeService $dj, @@ -877,6 +881,10 @@ public function getScoreboardTwigData( ], 'static' => $static, ]; + if ($static && $contest && $contest->getFreezeData()->showFinal()) { + unset($data['refresh']); + $data['refreshstop'] = true; + } if ($contest) { if ($request && $response) { @@ -940,8 +948,17 @@ protected function getTeams(Contest $contest, bool $jury = false, Filter $filter ->setParameter('cid', $contest->getCid()); } + $show_filter = $this->config->get('show_teams_on_scoreboard'); if (!$jury) { $queryBuilder->andWhere('tc.visible = 1'); + if ($show_filter === self::SHOW_TEAM_AFTER_LOGIN) { + $queryBuilder + ->join('t.users', 'u', Join::WITH, 'u.last_login IS NOT NULL OR u.last_api_login IS NOT NULL'); + } elseif ($show_filter === self::SHOW_TEAM_AFTER_SUBMIT) { + $queryBuilder + ->join('t.submissions', 's', Join::WITH, 's.contest = :cid') + ->setParameter('cid', $contest->getCid()); + } } if ($filter) { diff --git a/webapp/src/Service/SubmissionService.php b/webapp/src/Service/SubmissionService.php index 789a3e0a865..73f656e9e56 100644 --- a/webapp/src/Service/SubmissionService.php +++ b/webapp/src/Service/SubmissionService.php @@ -149,6 +149,13 @@ public function getSubmissionList( $queryBuilder->andWhere('j.result IS NULL OR j.endtime IS NULL'); } } + if (isset($restrictions['judging'])) { + if ($restrictions['judging']) { + $queryBuilder->andWhere('j.starttime IS NOT NULL AND j.result IS NULL'); + } else { + $queryBuilder->andWhere('j.starttime IS NULL OR j.result IS NOT NULL'); + } + } if (isset($restrictions['externally_judged'])) { if ($restrictions['externally_judged']) { diff --git a/webapp/src/Twig/TwigExtension.php b/webapp/src/Twig/TwigExtension.php index 3161547716a..eeda610b61a 100644 --- a/webapp/src/Twig/TwigExtension.php +++ b/webapp/src/Twig/TwigExtension.php @@ -68,7 +68,7 @@ public function getFilters(): array { return [ new TwigFilter('printtimediff', $this->printtimediff(...)), - new TwigFilter('printremainingminutes', $this->printremainingminutes(...)), + new TwigFilter('printelapsedminutes', $this->printelapsedminutes(...)), new TwigFilter('printtime', $this->printtime(...)), new TwigFilter('printHumanTimeDiff', $this->printHumanTimeDiff(...)), new TwigFilter('printtimeHover', $this->printtimeHover(...), ['is_safe' => ['html']]), @@ -162,15 +162,15 @@ public function printtimediff(float $start, ?float $end = null): string return Utils::printtimediff($start, $end); } - public function printremainingminutes(float $start, float $end): string + public function printelapsedminutes(float $start, float $end): string { - $minutesRemaining = floor(($end - $start)/60); - if ($minutesRemaining < 1) { - return 'less than 1 minute to go'; - } elseif ($minutesRemaining == 1) { - return '1 minute to go'; + $minutesElapsed = floor(($end - $start)/60); + if ($minutesElapsed < 1) { + return 'started less than 1 minute ago'; + } elseif ($minutesElapsed == 1) { + return 'started 1 minute ago'; } else { - return $minutesRemaining . ' minutes to go'; + return 'started' . $minutesElapsed . ' minutes ago'; } } diff --git a/webapp/src/Utils/Utils.php b/webapp/src/Utils/Utils.php index 2dfded802c6..17199ba908b 100644 --- a/webapp/src/Utils/Utils.php +++ b/webapp/src/Utils/Utils.php @@ -335,7 +335,7 @@ public static function parseHexColor(string $hex): array } /** - * Comvert an RGB component to its hex value. + * Convert an RGB component to its hex value. */ public static function componentToHex(int $component): string { diff --git a/webapp/templates/jury/executables.html.twig b/webapp/templates/jury/executables.html.twig index cd5276371e7..159492c57de 100644 --- a/webapp/templates/jury/executables.html.twig +++ b/webapp/templates/jury/executables.html.twig @@ -10,9 +10,11 @@ {% block content %} -
diff --git a/webapp/templates/jury/jury_macros.twig b/webapp/templates/jury/jury_macros.twig index 6ac791fff64..91483088454 100644 --- a/webapp/templates/jury/jury_macros.twig +++ b/webapp/templates/jury/jury_macros.twig @@ -140,6 +140,10 @@ {{- (item.value|default(item.default|default('')))|affiliationLogo(item.title) -}} {% elseif key == "warning_content" %} {{- item.value|printWarningContent -}} + {% elseif key == "badges" %} + {% for badge in item.value %} + {{- badge|problemBadge }} + {% endfor %} {% elseif (column.render | default('')) == "entity_id_badge" %} {% if item.value %}{{- item.value|entityIdBadge(item.idPrefix|default('')) -}}{% endif %} {% else %} diff --git a/webapp/templates/jury/partials/external_contest_info.html.twig b/webapp/templates/jury/partials/external_contest_info.html.twig index 49797d3309f..ab0c460c917 100644 --- a/webapp/templates/jury/partials/external_contest_info.html.twig +++ b/webapp/templates/jury/partials/external_contest_info.html.twig @@ -43,6 +43,8 @@