diff --git a/misc-tools/.gitignore b/misc-tools/.gitignore index bb48b82358..da724f0b2e 100644 --- a/misc-tools/.gitignore +++ b/misc-tools/.gitignore @@ -5,4 +5,5 @@ /dj_make_chroot_docker /dj_run_chroot /dj_judgehost_cleanup +/dj_utils.py /force-passwords diff --git a/misc-tools/Makefile b/misc-tools/Makefile index eaa0d6345a..d216c138cc 100644 --- a/misc-tools/Makefile +++ b/misc-tools/Makefile @@ -9,7 +9,8 @@ include $(TOPDIR)/Makefile.global TARGETS = OBJECTS = -SUBST_DOMSERVER = fix_permissions configure-domjudge import-contest force-passwords +SUBST_DOMSERVER = fix_permissions configure-domjudge dj_utils.py \ + import-contest export-contest force-passwords SUBST_JUDGEHOST = dj_make_chroot dj_run_chroot dj_make_chroot_docker \ dj_judgehost_cleanup diff --git a/misc-tools/configure-domjudge.in b/misc-tools/configure-domjudge.in index 360bb8e163..b58660b5ac 100755 --- a/misc-tools/configure-domjudge.in +++ b/misc-tools/configure-domjudge.in @@ -24,8 +24,6 @@ from typing import List, Set sys.path.append('@domserver_libdir@') import dj_utils -webappdir = '@domserver_webappdir@' - def usage(): print(f'Usage: {sys.argv[0]} []') exit(1) @@ -57,11 +55,9 @@ def _keyify_list(l: List) -> Set: return { elem['id']: elem for elem in l } -if len(sys.argv) == 1: - dj_utils.domjudge_webapp_folder_or_api_url = webappdir -elif len(sys.argv) == 2: - dj_utils.domjudge_webapp_folder_or_api_url = sys.argv[1] -else: +if len(sys.argv) == 2: + dj_utils.domjudge_api_url = sys.argv[1] +elif len(sys.argv) != 1: usage() user_data = dj_utils.do_api_request('user') @@ -88,7 +84,7 @@ if os.path.exists('config.json'): print(f' - missing keys from new config = {missing_keys}') if diffs or new_keys or missing_keys: if dj_utils.confirm(' - Upload these configuration changes?', True): - actual_config = dj_utils.do_api_request('config', 'PUT', expected_config) + actual_config = dj_utils.do_api_request('config', 'PUT', jsonData=expected_config) diffs, new_keys, missing_keys = compare_configs( actual_config=actual_config, expected_config=expected_config diff --git a/misc-tools/dj_utils.py b/misc-tools/dj_utils.py deleted file mode 100644 index dd27b9865f..0000000000 --- a/misc-tools/dj_utils.py +++ /dev/null @@ -1,189 +0,0 @@ -''' -dj_utils -- Utility functions for other convenience scripts that come with DOMjudge. - -Part of the DOMjudge Programming Contest Jury System and licensed -under the GNU GPL. See README and COPYING for details. -''' - -import json -import os -import requests -import requests.utils -import subprocess -import sys -from urllib.parse import urlparse - -_myself = os.path.basename(sys.argv[0]) -_default_user_agent = requests.utils.default_user_agent() -headers = {'user-agent': f'dj_utils/{_myself} ({_default_user_agent})'} -domjudge_webapp_folder_or_api_url = 'unset' -ca_check = True - - -def confirm(message: str, default: bool) -> bool: - answer = 'x' - while answer not in ['y', 'n']: - yn = 'Y/n' if default else 'y/N' - answer = input(f'{message} ({yn}) ').lower() - if answer == '': - answer = 'y' if default else 'n' - return answer == 'y' - - -def parse_api_response(name: str, response: requests.Response): - # The connection worked, but we may have received an HTTP error - if response.status_code >= 300: - print(response.text) - if response.status_code == 401: - raise RuntimeError( - 'Authentication failed, please check your DOMjudge credentials in ~/.netrc.') - else: - raise RuntimeError( - f'API request {name} failed (code {response.status_code}).') - - if response.status_code == 204: - return None - - # We got a successful HTTP response. It worked. Return the full response - try: - result = json.loads(response.text) - except json.decoder.JSONDecodeError: - print(response.text) - raise RuntimeError(f'Failed to JSON decode the response for API request {name}') - - return result - - -def do_api_request(name: str, method: str = 'GET', jsonData: dict = {}): - '''Perform an API call to the given endpoint and return its data. - - Based on whether `domjudge_webapp_folder_or_api_url` is a folder or URL this - will use the DOMjudge CLI or HTTP API. - - Parameters: - name (str): the endpoint to call - method (str): the method to use, GET or PUT are supported - jsonData (dict): the JSON data to PUT. Only used when method is PUT - - Returns: - The endpoint contents. - - Raises: - RuntimeError when the response is not JSON or the HTTP status code is non 2xx. - ''' - - if os.path.isdir(domjudge_webapp_folder_or_api_url): - return api_via_cli(name, method, {}, {}, jsonData) - else: - global ca_check - url = f'{domjudge_webapp_folder_or_api_url}/{name}' - parsed = urlparse(domjudge_webapp_folder_or_api_url) - auth = None - if parsed.username and parsed.password: - auth = (parsed.username, parsed.password) - - try: - if method == 'GET': - response = requests.get(url, headers=headers, verify=ca_check, auth=auth) - elif method == 'PUT': - response = requests.put(url, headers=headers, verify=ca_check, json=jsonData, auth=auth) - else: - raise RuntimeError("Method not supported") - except requests.exceptions.SSLError: - ca_check = not confirm( - "Can not verify certificate, ignore certificate check?", False) - if ca_check: - print('Can not verify certificate chain for DOMserver.') - exit(1) - else: - return do_api_request(name) - except requests.exceptions.RequestException as e: - raise RuntimeError(e) - return parse_api_response(name, response) - - -def upload_file(name: str, apifilename: str, file: str, data: dict = {}): - '''Upload the given file to the API at the given path with the given name. - - Based on whether `domjudge_webapp_folder_or_api_url` is a folder or URL this - will use the DOMjudge CLI or HTTP API. - - Parameters: - name (str): the endpoint to call - apifilename (str): the argument name for the file to upload - file (str): the file to upload - - Returns: - The parsed endpoint contents. - - Raises: - RuntimeError when the HTTP status code is non 2xx. - ''' - - if os.path.isdir(domjudge_webapp_folder_or_api_url): - return api_via_cli(name, 'POST', data, {apifilename: file}) - else: - global ca_check - files = [(apifilename, open(file, 'rb'))] - - url = f'{domjudge_webapp_folder_or_api_url}/{name}' - - try: - response = requests.post( - url, files=files, headers=headers, data=data, verify=ca_check) - except requests.exceptions.SSLError: - ca_check = not confirm( - "Can not verify certificate, ignore certificate check?", False) - if ca_check: - print('Can not verify certificate chain for DOMserver.') - exit(1) - else: - response = requests.post( - url, files=files, headers=headers, data=data, verify=ca_check) - - return parse_api_response(name, response) - - -def api_via_cli(name: str, method: str = 'GET', data: dict = {}, files: dict = {}, jsonData: dict = {}): - '''Perform the given API request using the CLI - - Parameters: - name (str): the endpoint to call - method (str): the method to use. Either GET, POST or PUT - data (dict): the POST data to use. Only used when method is POST or PUT - files (dict): the files to use. Only used when method is POST or PUT - jsonData (dict): the JSON data to use. Only used when method is POST or PUT - - Returns: - The parsed endpoint contents. - - Raises: - RuntimeError when the command exit code is not 0. - ''' - - command = [ - f'{domjudge_webapp_folder_or_api_url}/bin/console', - 'api:call', - '-m', - method - ] - - for item in data: - command.extend(['-d', f'{item}={data[item]}']) - - for item in files: - command.extend(['-f', f'{item}={files[item]}']) - - if jsonData: - command.extend(['-j', json.dumps(jsonData)]) - - command.append(name) - - result = subprocess.run(command, capture_output=True) - response = result.stdout.decode('ascii') - - if result.returncode != 0: - print(response) - raise RuntimeError(f'API request {name} failed') - - return json.loads(response) diff --git a/misc-tools/dj_utils.py.in b/misc-tools/dj_utils.py.in new file mode 100644 index 0000000000..5a93bb13de --- /dev/null +++ b/misc-tools/dj_utils.py.in @@ -0,0 +1,204 @@ +''' +dj_utils -- Utility functions for other convenience scripts that come with DOMjudge. + +Part of the DOMjudge Programming Contest Jury System and licensed +under the GNU GPL. See README and COPYING for details. +''' + +import json +import os +import requests +import requests.utils +import subprocess +import sys +from urllib.parse import urlparse, urljoin + +_myself = os.path.basename(sys.argv[0]) +_default_user_agent = requests.utils.default_user_agent() +headers = {'user-agent': f'dj_utils/{_myself} ({_default_user_agent})'} +domjudge_webapp_dir = '@domserver_webappdir@' +domjudge_api_url = None +ca_check = True + + +def confirm(message: str, default: bool) -> bool: + answer = 'x' + while answer not in ['y', 'n']: + yn = 'Y/n' if default else 'y/N' + answer = input(f'{message} ({yn}) ').lower() + if answer == '': + answer = 'y' if default else 'n' + return answer == 'y' + + +def parse_api_response(endpoint: str, response: requests.Response) -> bytes: + # The connection worked, but we may have received an HTTP error + if response.status_code >= 300: + print(response.text) + if response.status_code == 401: + raise RuntimeError( + 'Authentication failed, please check your DOMjudge credentials in ~/.netrc.') + else: + raise RuntimeError( + f'API request {endpoint} failed (code {response.status_code}).') + + if response.status_code == 204: + return None + + return response.content + + +def is_relative(url: str) -> bool: + parsed = urlparse(url) + + return (parsed.scheme=='' and parsed.netloc=='' and + (len(parsed.path)==0 or parsed.path[0]!='/')) + + +def do_api_request(endpoint: str, method: str = 'GET', *, + data: dict = {}, files: dict = {}, jsonData: dict = {}, + decode: bool = True, output_file: str = None): + '''Perform an API call to the given endpoint and return its data. + + Based on whether `domjudge_api_url` is set, this will call the DOMjudge + API via HTTP or CLI. + + Parameters: + endpoint (str): the endpoint to call, relative to `domjudge_api_url`; + it may also contain a complete URL, which will then be used as-is + method (str): the method to use, GET or PUT are supported + jsonData (dict): the JSON data to PUT. Only used when method is PUT + data (dict): data to pass in a POST request + decode (bool): whether to decode the returned JSON data, default true + output_file (str): write API response to file, e.g. for binary content; + incompatible with decode=True. + + Returns: + The endpoint contents, either as raw bytes or JSON decoded, unless + output_file is specified. + + Raises: + RuntimeError when the HTTP status code is non-2xx or the response + cannot be JSON decoded. + ''' + + # For the recursive call below to ignore SSL validation: + orig_kwargs = locals() + + if domjudge_api_url is None and is_relative(endpoint): + result = api_via_cli(endpoint, method, jsonData=jsonData, output_file=output_file) + else: + if not is_relative(endpoint): + raise RuntimeError(f'Cannot access non-relative URL {endpoint} without API base URL') + global ca_check + parsed = urlparse(domjudge_api_url) + url = urljoin(domjudge_api_url, endpoint) + auth = None + if parsed.username and parsed.password: + auth = (parsed.username, parsed.password) + + kwargs = { + 'headers': headers, + 'verify': ca_check, + 'auth': auth, + } + if method == 'GET': + pass + elif method == 'PUT': + kwargs['json'] = jsonData + elif method == 'POST': + kwargs['data'] = data + kwargs['files'] = [(name, open(file, 'rb')) for (name, file) in files] + else: + raise RuntimeError(f"Method {method} not supported") + + try: + response = requests.request(method, url, **kwargs) + except requests.exceptions.SSLError: + ca_check = not confirm( + "Can not verify certificate, ignore certificate check?", False) + if ca_check: + raise RuntimeError(f'Cannot verify certificate chain for {url}') + else: + # Retry with SSL verification disabled + return do_api_request(**orig_kwargs) + except requests.exceptions.RequestException as e: + raise RuntimeError(e) + result = parse_api_response(endpoint, response) + + if output_file is not None: + with open(output_file, 'wb') as f: + f.write(result) + + if decode: + try: + result = json.loads(result) + except json.decoder.JSONDecodeError as e: + print(result) + raise RuntimeError(f'Failed to JSON decode the response for API request {endpoint}') + + return result + + +def upload_file(endpoint: str, apifilename: str, file: str, data: dict = {}): + '''Upload the given file to the API endpoint as apifilename. + + Thin wrapper around do_api_request, see that function for more details. + ''' + + do_api_request(endpoint, 'POST', data=data, files={apifilename: file}) + + +def api_via_cli(endpoint: str, method: str = 'GET', *, + data: dict = {}, files: dict = {}, jsonData: dict = {}, + output_file: str = None): + '''Perform the given API request using the CLI + + Parameters: + endpoint (str): the endpoint to call, relative to API base url + method (str): the method to use. Either GET, POST or PUT + data (dict): the POST data to use. Only used when method is POST or PUT + files (dict): the files to use. Only used when method is POST or PUT + jsonData (dict): the JSON data to use. Only used when method is POST or PUT + output_file (str): write API response to file, e.g. for binary content + + Returns: + The raw endpoint contents. + + Raises: + RuntimeError when the command exit code is not 0. + ''' + + command = [ + f'{domjudge_webapp_dir}/bin/console', + 'api:call', + '-m', + method + ] + + for item in data: + command.extend(['-d', f'{item}={data[item]}']) + + for item in files: + command.extend(['-f', f'{item}={files[item]}']) + + if jsonData: + command.extend(['-j', json.dumps(jsonData)]) + + if output_file: + command.extend(['-o', output_file]) + + command.append(endpoint) + + result = subprocess.run(command, capture_output=True) + + if result.returncode != 0: + print( + f"Command: {command}\nOutput:\n" + + result.stdout.decode('utf-8') + + result.stderr.decode('utf-8'), + file=sys.stderr + ) + raise RuntimeError(f'API request {endpoint} failed') + + return result.stdout diff --git a/misc-tools/export-contest.in b/misc-tools/export-contest.in new file mode 100755 index 0000000000..39957b93c5 --- /dev/null +++ b/misc-tools/export-contest.in @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 + +''' +export-contest -- Convenience script to export a contest (including metadata, +teams and problems) from the command line. Defaults to using the CLI interface; +Specify a DOMjudge API URL as to use that. + +Reads credentials from ~/.netrc when using the API. + +Part of the DOMjudge Programming Contest Jury System and licensed +under the GNU GPL. See README and COPYING for details. +''' + +import datetime +import json +import os +import sys +import time +from argparse import ArgumentParser +from concurrent.futures import ThreadPoolExecutor, as_completed + +sys.path.append('@domserver_libdir@') +import dj_utils + +mime_to_extension = { + 'application/pdf': 'pdf', + 'application/zip': 'zip', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/svg+xml': 'svg', + 'text/plain': 'txt', + 'video/mp4': 'mp4', + 'video/mpeg': 'mpg', + 'video/webm': 'webm', +} + +def get_default_contest(): + c_default = None + + contests = dj_utils.do_api_request('contests') + if len(contests)>0: + now = int(time.time()) + for c in contests: + if 'start_time' not in c or c['start_time'] is None: + # Assume that a contest with start time unset will start soon. + c['start_epoch'] = now + 1 + else: + c['start_epoch'] = datetime.datetime.fromisoformat(c['start_time']).timestamp() + + c_default = contests[0] + for c in contests: + if c_default['start_epoch']<=now: + if c['start_epoch']<=now and c['start_epoch']>c_default['start_epoch']: + c_default = c + else: + if c['start_epoch'] bool: + ''' + Check whether API data represents a FILE object. This is heuristic because + no property is strictly required, but we need at least `href` to download + the file, so if also we find one other property, we announce a winner. + ''' + if not isinstance(data, dict): + return false + return 'href' in data and ('mime' in data or 'filename' in data or 'hash' in data) + + +files_to_download = [] + +def recurse_find_files(data, store_path: str, default_name: str): + if isinstance(data, list): + # Special case single element list for simpler default_name + if len(data) == 1: + recurse_find_files(data[0], store_path, default_name) + else: + for i, item in enumerate(data): + recurse_find_files(item, store_path, f"{default_name}.{i}") + elif isinstance(data, dict): + if is_file(data): + if 'mime' in data and data['mime'] in mime_to_extension: + default_name += '.' + mime_to_extension[data['mime']] + files_to_download.append((data, store_path, default_name)) + else: + for key, item in data.items(): + recurse_find_files(item, store_path, f"{default_name}.{key}") + + +def download_endpoint(name: str, path: str): + ext = '.ndjson' if name == 'event-feed' else '.json' + filename = name + ext + + print(f"Fetching '{path}' to '{filename}'") + data = dj_utils.do_api_request(path, decode=False) + with open(filename, 'wb') as f: + f.write(data) + + if ext == '.json': + data = json.loads(data) + store_path = name + if isinstance(data, list): + for elem in data: + recurse_find_files(elem, f"{store_path}/{elem['id']}", '') + else: + recurse_find_files(data, store_path, '') + + +cid = None +dir = None + +parser = ArgumentParser(description='Export a contest archive from DOMjudge via the API.') +parser.add_argument('-c', '--cid', help="contest ID to export, defaults to last started, or else first non-started active contest") +parser.add_argument('-d', '--dir', help="directory to write the contest archive to, defaults to contest ID in current directory") +parser.add_argument('-u', '--url', help="DOMjudge API URL to use, if not specified use the CLI interface") +args = parser.parse_args() + +if args.cid: + cid = args.cid +else: + c = get_default_contest() + if c is None: + print("No contest specified nor an active contest found.") + exit(1) + else: + cid = c['id'] + +if args.dir: + dir = args.dir +else: + dir = cid + +if args.url: + dj_utils.domjudge_api_url = args.url + +user_data = dj_utils.do_api_request('user') +if 'admin' not in user_data['roles']: + print('Your user does not have the \'admin\' role, can not export.') + exit(1) + +if os.path.exists(dir): + print(f'Export directory \'{dir}\' already exists, will not overwrite.') + exit(1) + +os.makedirs(dir) +os.chdir(dir) + +contest_path = f'contests/{cid}' + +# Custom endpoints: +download_endpoint('api', '') +download_endpoint('contest', contest_path) +download_endpoint('event-feed', f'{contest_path}/event-feed?stream=false') + +for endpoint in [ + 'access', + 'accounts', + 'awards', +# 'balloons', This is a DOMjudge specific endpoint + 'clarifications', +# 'commentary', Not implemented in DOMjudge + 'groups', + 'judgement-types', + 'judgements', + 'languages', + 'organizations', +# 'persons', Not implemented in DOMjudge + 'problems', + 'runs', + 'scoreboard', + 'state', + 'submissions', + 'teams', + ]: + download_endpoint(endpoint, f"{contest_path}/{endpoint}") + +with ThreadPoolExecutor(20) as executor: + futures = [executor.submit(download_file, *item) for item in files_to_download] + for future in as_completed(futures): + future.result() # So it can throw any exception diff --git a/misc-tools/import-contest.in b/misc-tools/import-contest.in index cb940e2334..53201f6a80 100755 --- a/misc-tools/import-contest.in +++ b/misc-tools/import-contest.in @@ -15,7 +15,8 @@ Part of the DOMjudge Programming Contest Jury System and licensed under the GNU GPL. See README and COPYING for details. ''' -from os import listdir +from argparse import ArgumentParser +from os import chdir, listdir from typing import List import json import os.path @@ -29,13 +30,16 @@ sys.path.append('@domserver_libdir@') import dj_utils cid = None -webappdir = '@domserver_webappdir@' +parser = ArgumentParser(description='Import a contest archive to DOMjudge via the API.') +parser.add_argument('-d', '--dir', help="directory containing the contest archive, defaults to current directory") +parser.add_argument('-u', '--url', help="DOMjudge API URL to use, if not specified use the CLI interface") +args = parser.parse_args() -def usage(): - print(f'Usage: {sys.argv[0]} []') - exit(1) - +if args.dir: + chdir(args.dir) +if args.url: + dj_utils.domjudge_api_url = args.url def import_file(entity: str, files: List[str]) -> bool: any_matched = False @@ -134,13 +138,6 @@ def import_contest_problemset_document(cid: str): else: print('Skipping contest problemset import.') -if len(sys.argv) == 1: - dj_utils.domjudge_webapp_folder_or_api_url = webappdir -elif len(sys.argv) == 2: - dj_utils.domjudge_webapp_folder_or_api_url = sys.argv[1] -else: - usage() - user_data = dj_utils.do_api_request('user') if 'admin' not in user_data['roles']: print('Your user does not have the \'admin\' role, can not import.') diff --git a/webapp/src/Command/CallApiActionCommand.php b/webapp/src/Command/CallApiActionCommand.php index 47d98c2671..a2a511dbb5 100644 --- a/webapp/src/Command/CallApiActionCommand.php +++ b/webapp/src/Command/CallApiActionCommand.php @@ -65,6 +65,12 @@ protected function configure(): void 'u', InputOption::VALUE_REQUIRED, 'User to use for API requests. If not given, the first admin user will be used' + ) + ->addOption( + 'output', + 'o', + InputOption::VALUE_REQUIRED, + 'Filename to write output to. Useful for binary content' ); } @@ -144,7 +150,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } try { - $response = null; + $response = ''; $this->dj->withAllRoles(function () use ($input, $data, $files, &$response) { $response = $this->dj->internalApiRequest('/' . $input->getArgument('endpoint'), $input->getOption('method'), $data, $files, true); }, $user); @@ -154,7 +160,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - $output->writeln(json_encode($response, JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + if ($filename = $input->getOption('output')) { + $fd = fopen($filename, 'w'); + fwrite($fd, $response); + fclose($fd); + } else { + $output->write($response); + } return Command::SUCCESS; } } diff --git a/webapp/src/Controller/API/ContestController.php b/webapp/src/Controller/API/ContestController.php index 8565c31589..f6816e6938 100644 --- a/webapp/src/Controller/API/ContestController.php +++ b/webapp/src/Controller/API/ContestController.php @@ -871,7 +871,7 @@ public function getEventFeedAction( } echo Utils::jsonEncode($result) . "\n"; - ob_flush(); + Utils::ob_flush_if_possible(); flush(); $lastUpdate = Utils::now(); $lastIdSent = $event->getEventid(); @@ -896,7 +896,7 @@ public function getEventFeedAction( # Send keep alive every 10s. Guarantee according to spec is 120s. # However, nginx drops the connection if we don't update for 60s. echo "\n"; - ob_flush(); + Utils::ob_flush_if_possible(); flush(); $lastUpdate = $now; } diff --git a/webapp/src/Service/DOMJudgeService.php b/webapp/src/Service/DOMJudgeService.php index 8571dfa81f..eac465d07c 100644 --- a/webapp/src/Service/DOMJudgeService.php +++ b/webapp/src/Service/DOMJudgeService.php @@ -43,6 +43,7 @@ use ReflectionClass; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\InputBag; @@ -652,13 +653,16 @@ public function internalApiRequest(string $url, string $method = Request::METHOD return null; } - $content = $response->getContent(); - - if ($content === '') { - return null; + if ($response instanceof StreamedResponse || + $response instanceof BinaryFileResponse) { + ob_start(flags: PHP_OUTPUT_HANDLER_REMOVABLE); + $response->sendContent(); + $content = ob_get_clean(); + } else { + $content = $response->getContent(); } - return Utils::jsonDecode($content); + return $content; } public function getDomjudgeEtcDir(): string diff --git a/webapp/src/Service/EventLogService.php b/webapp/src/Service/EventLogService.php index e400701c01..257afc358f 100644 --- a/webapp/src/Service/EventLogService.php +++ b/webapp/src/Service/EventLogService.php @@ -315,13 +315,13 @@ public function log( $query = ['ids' => $ids]; } - $this->dj->withAllRoles(function () use ($query, $url, &$json) { - $json = $this->dj->internalApiRequest($url, Request::METHOD_GET, $query); + $this->dj->withAllRoles(function () use ($query, $url, &$response) { + $response = $this->dj->internalApiRequest($url, Request::METHOD_GET, $query); }); - if ($json === null) { + if ($response === '') { $this->logger->warning( - "EventLogService::log got no JSON data from '%s'", [ $url ] + "EventLogService::log got no data from '%s'", [ $url ] ); // If we didn't get data from the API, then that is probably // because this particular data is not visible, for example @@ -329,6 +329,9 @@ public function log( // have data, there's also no point in trying to insert // anything in the eventlog table. return; + $json = null; + } else { + $json = Utils::jsonDecode($response); } } @@ -484,7 +487,8 @@ public function addMissingStateEvents(Contest $contest): void $url = sprintf('/contests/%s/awards', $contest->getExternalid()); $awards = []; $this->dj->withAllRoles(function () use ($url, &$awards) { - $awards = $this->dj->internalApiRequest($url); + $response = $this->dj->internalApiRequest($url); + if ( !empty($response) ) $awards = Utils::jsonDecode($response); }); foreach ($awards as $award) { $this->insertEvent($contest, 'awards', $award['id'], $award); @@ -625,7 +629,8 @@ public function initStaticEvents(Contest $contest): void $urlPart = $endpoint === 'contests' ? '' : ('/' . $endpoint); $url = sprintf('/contests/%s%s', $contestId, $urlPart); $this->dj->withAllRoles(function () use ($url, &$data) { - $data = $this->dj->internalApiRequest($url); + $response = $this->dj->internalApiRequest($url); + $data = (empty($response) ? null : Utils::jsonDecode($response)); }); // Get a partial reference to the contest, diff --git a/webapp/src/Utils/Utils.php b/webapp/src/Utils/Utils.php index 7d0a191a02..3ee9392380 100644 --- a/webapp/src/Utils/Utils.php +++ b/webapp/src/Utils/Utils.php @@ -1023,4 +1023,16 @@ public static function extendMaxExecutionTime(int $minimumMaxExecutionTime): voi ini_set('max_execution_time', $minimumMaxExecutionTime); } } + + /** + * Call ob_flush() unless the top-level output buffer does not allow it. + */ + public static function ob_flush_if_possible(): bool + { + $status = ob_get_status(); + if ( empty($status) || ($status['flags'] & PHP_OUTPUT_HANDLER_CLEANABLE) ) { + return ob_flush(); + } + return false; + } }