diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8687890c..16cc9c75 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -24,7 +24,10 @@ "Vue.volar" ] } - } + }, + + // Install pip requirements + "postCreateCommand": "pip install -r requirements.txt" // Features to add to the dev container. More info: https://containers.dev/features. // "features": {}, @@ -35,9 +38,6 @@ // Uncomment the next line if you want to keep your containers running after VS Code shuts down. // "shutdownAction": "none", - // Uncomment the next line to run commands after the container is created. - // "postCreateCommand": "cat /etc/os-release", - // Configure tool-specific properties. // "customizations": {}, } diff --git a/.gitignore b/.gitignore index e1bddf03..3732e540 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,17 @@ nginx/ssl/keys/* !**/.gitkeep **/node_modules **/junit.xml + +# Screenshots +logs/screenshots/* +!logs/screenshots/.gitkeep + +# Fish shell +$HOME + +# JetBrains IDEs +.idea + # Created by https://www.toptal.com/developers/gitignore/api/intellij,python,flask,macos # Edit at https://www.toptal.com/developers/gitignore?templates=intellij,python,flask,macos diff --git a/Dockerfile b/Dockerfile index b7bd1020..9439b3d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,7 +54,11 @@ RUN cp /app/scripts/daemon/xvfb /etc/init.d/xvfb # Install python packages COPY requirements.txt /app/requirements.txt RUN pip install --user -r /app/requirements.txt +RUN apt-get install python3-tk python3-xlib gnome-screenshot -y +# Initiate PyAutoGUI +RUN touch /root/.Xauthority && \ + xauth add ${HOST}:0 . $(xxd -l 16 -p /dev/urandom) FROM base AS core # Copy rest of source code diff --git a/bci/browser/automation/terminal.py b/bci/browser/automation/terminal.py index d1fe6f70..ddc08b8e 100644 --- a/bci/browser/automation/terminal.py +++ b/bci/browser/automation/terminal.py @@ -7,22 +7,26 @@ class TerminalAutomation: - @staticmethod - def run(url: str, args: list[str], seconds_per_visit: int): - logger.debug("Starting browser process...") + def visit_url(url: str, args: list[str], seconds_per_visit: int): args.append(url) + proc = TerminalAutomation.open_browser(args) + logger.debug(f'Visiting the page for {seconds_per_visit}s') + time.sleep(seconds_per_visit) + TerminalAutomation.terminate_browser(proc, args) + + @staticmethod + def open_browser(args: list[str]) -> subprocess.Popen: + logger.debug('Starting browser process...') logger.debug(f'Command string: \'{" ".join(args)}\'') - with open('/tmp/browser.log', 'a') as file: - proc = subprocess.Popen( - args, - stdout=file, - stderr=file - ) + with open('/tmp/browser.log', 'a+') as file: + proc = subprocess.Popen(args, stdout=file, stderr=file) + return proc - time.sleep(seconds_per_visit) + @staticmethod + def terminate_browser(proc: subprocess.Popen, args: list[str]) -> None: + logger.debug('Terminating browser process using SIGINT...') - logger.debug(f'Terminating browser process after {seconds_per_visit}s using SIGINT...') # Use SIGINT and SIGTERM to end process such that cookies remain saved. proc.send_signal(signal.SIGINT) proc.send_signal(signal.SIGTERM) @@ -30,8 +34,8 @@ def run(url: str, args: list[str], seconds_per_visit: int): try: stdout, stderr = proc.communicate(timeout=5) except subprocess.TimeoutExpired: - logger.info("Browser process did not terminate after 5s. Killing process through pkill...") + logger.info('Browser process did not terminate after 5s. Killing process through pkill...') subprocess.run(['pkill', '-2', args[0].split('/')[-1]]) proc.wait() - logger.debug("Browser process terminated.") + logger.debug('Browser process terminated.') diff --git a/bci/browser/configuration/browser.py b/bci/browser/configuration/browser.py index 1178ece1..f6a9f2e7 100644 --- a/bci/browser/configuration/browser.py +++ b/bci/browser/configuration/browser.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import subprocess from abc import abstractmethod import bci.browser.binary.factory as binary_factory @@ -15,9 +16,13 @@ class Browser: + process: subprocess.Popen | None - def __init__(self, browser_config: BrowserConfiguration, eval_config: EvaluationConfiguration, binary: Binary) -> None: + def __init__( + self, browser_config: BrowserConfiguration, eval_config: EvaluationConfiguration, binary: Binary + ) -> None: self.browser_config = browser_config + self.process = None self.eval_config = eval_config self.binary = binary self.state = binary.state @@ -34,10 +39,22 @@ def visit(self, url: str): match self.eval_config.automation: case 'terminal': args = self._get_terminal_args() - TerminalAutomation.run(url, args, self.eval_config.seconds_per_visit) + TerminalAutomation.visit_url(url, args, self.eval_config.seconds_per_visit) case _: raise AttributeError('Not implemented') + def open(self, url: str) -> None: + args = self._get_terminal_args() + args.append(url) + self.process = TerminalAutomation.open_browser(args) + + def terminate(self): + if self.process is None: + return + + TerminalAutomation.terminate_browser(self.process, self._get_terminal_args()) + self.process = None + def pre_evaluation_setup(self): self.__fetch_binary() @@ -80,11 +97,17 @@ def _get_executable_file_path(self) -> str: return os.path.join(self.__get_execution_folder_path(), self.binary.executable_name) @abstractmethod - def _get_terminal_args(self): + def _get_terminal_args(self) -> list[str]: + pass + + @abstractmethod + def get_navigation_sleep_duration(self) -> int: pass @staticmethod - def get_browser(browser_config: BrowserConfiguration, eval_config: EvaluationConfiguration, state: State) -> Browser: + def get_browser( + browser_config: BrowserConfiguration, eval_config: EvaluationConfiguration, state: State + ) -> Browser: from bci.browser.configuration.chromium import Chromium from bci.browser.configuration.firefox import Firefox diff --git a/bci/browser/configuration/chromium.py b/bci/browser/configuration/chromium.py index 6108328d..f2cd16cb 100644 --- a/bci/browser/configuration/chromium.py +++ b/bci/browser/configuration/chromium.py @@ -32,6 +32,9 @@ class Chromium(Browser): + def get_navigation_sleep_duration(self) -> int: + return 1 + def _get_terminal_args(self) -> list[str]: assert self._profile_path is not None diff --git a/bci/browser/configuration/firefox.py b/bci/browser/configuration/firefox.py index 643ae57c..cff1d7f1 100644 --- a/bci/browser/configuration/firefox.py +++ b/bci/browser/configuration/firefox.py @@ -2,10 +2,9 @@ from bci import cli from bci.browser.configuration.browser import Browser -from bci.browser.configuration.options import Default, BlockThirdPartyCookies, PrivateBrowsing, TrackingProtection +from bci.browser.configuration.options import BlockThirdPartyCookies, Default, PrivateBrowsing, TrackingProtection from bci.browser.configuration.profile import prepare_firefox_profile - SUPPORTED_OPTIONS = [ Default(), BlockThirdPartyCookies(), @@ -21,11 +20,15 @@ class Firefox(Browser): + def get_navigation_sleep_duration(self) -> int: + return 2 + def _get_terminal_args(self) -> list[str]: assert self._profile_path is not None args = [self._get_executable_file_path()] args.extend(['-profile', self._profile_path]) + args.append('-setDefaultBrowser') user_prefs = [] def add_user_pref(key: str, value: str | int | bool): @@ -45,6 +48,7 @@ def add_user_pref(key: str, value: str | int | bool): # add_user_pref('network.proxy.type', 1) add_user_pref('app.update.enabled', False) + add_user_pref('browser.shell.checkDefaultBrowser', False) if 'default' in self.browser_config.browser_setting: pass elif 'btpc' in self.browser_config.browser_setting: diff --git a/bci/browser/interaction/__init__.py b/bci/browser/interaction/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bci/browser/interaction/elements/five.png b/bci/browser/interaction/elements/five.png new file mode 100644 index 00000000..92dfde60 Binary files /dev/null and b/bci/browser/interaction/elements/five.png differ diff --git a/bci/browser/interaction/elements/four.png b/bci/browser/interaction/elements/four.png new file mode 100644 index 00000000..055ece5b Binary files /dev/null and b/bci/browser/interaction/elements/four.png differ diff --git a/bci/browser/interaction/elements/one.png b/bci/browser/interaction/elements/one.png new file mode 100644 index 00000000..c87a2d0c Binary files /dev/null and b/bci/browser/interaction/elements/one.png differ diff --git a/bci/browser/interaction/elements/six.png b/bci/browser/interaction/elements/six.png new file mode 100644 index 00000000..4adc77b4 Binary files /dev/null and b/bci/browser/interaction/elements/six.png differ diff --git a/bci/browser/interaction/elements/three.png b/bci/browser/interaction/elements/three.png new file mode 100644 index 00000000..4368269e Binary files /dev/null and b/bci/browser/interaction/elements/three.png differ diff --git a/bci/browser/interaction/elements/two.png b/bci/browser/interaction/elements/two.png new file mode 100644 index 00000000..7f3c24a9 Binary files /dev/null and b/bci/browser/interaction/elements/two.png differ diff --git a/bci/browser/interaction/interaction.py b/bci/browser/interaction/interaction.py new file mode 100644 index 00000000..14fb8d1b --- /dev/null +++ b/bci/browser/interaction/interaction.py @@ -0,0 +1,57 @@ +import logging +from inspect import signature + +from bci.browser.configuration.browser import Browser as BrowserConfig +from bci.browser.interaction.simulation import Simulation +from bci.evaluations.logic import TestParameters + +logger = logging.getLogger(__name__) + + +class Interaction: + browser: BrowserConfig + script: list[str] + params: TestParameters + + def __init__(self, browser: BrowserConfig, script: list[str], params: TestParameters) -> None: + self.browser = browser + self.script = script + self.params = params + + def execute(self) -> None: + simulation = Simulation(self.browser, self.params) + + if self._interpret(simulation): + simulation.sleep(str(self.browser.get_navigation_sleep_duration())) + simulation.navigate('https://a.test/report/?bughog_sanity_check=OK') + + def _interpret(self, simulation: Simulation) -> bool: + for statement in self.script: + if statement.strip() == '' or statement[0] == '#': + continue + + cmd, *args = statement.split() + method_name = cmd.lower() + + if method_name not in Simulation.public_methods: + raise Exception( + f'Invalid command `{cmd}`. Expected one of {", ".join(map(lambda m: m.upper(), Simulation.public_methods))}.' + ) + + method = getattr(simulation, method_name) + method_params = list(signature(method).parameters.values()) + + # Allow different number of arguments only for variable argument number (*) + if len(method_params) != len(args) and (len(method_params) < 1 or str(method_params[0])[0] != '*'): + raise Exception( + f'Invalid number of arguments for command `{cmd}`. Expected {len(method_params)}, got {len(args)}.' + ) + + logger.debug(f'Executing interaction method `{method_name}` with the arguments {args}') + + try: + method(*args) + except: + return False + + return True diff --git a/bci/browser/interaction/simulation.py b/bci/browser/interaction/simulation.py new file mode 100644 index 00000000..d4ba899c --- /dev/null +++ b/bci/browser/interaction/simulation.py @@ -0,0 +1,85 @@ +import os +from time import sleep + +import pyautogui as gui +import Xlib.display +from pyvirtualdisplay.display import Display + +from bci.browser.configuration.browser import Browser as BrowserConfig +from bci.evaluations.logic import TestParameters + + +class Simulation: + browser_config: BrowserConfig + params: TestParameters + + public_methods: list[str] = [ + 'navigate', + 'click_position', + 'click', + 'write', + 'press', + 'hold', + 'release', + 'hotkey', + 'sleep', + 'screenshot', + ] + + def __init__(self, browser_config: BrowserConfig, params: TestParameters): + self.browser_config = browser_config + self.params = params + disp = Display(visible=True, size=(1920, 1080), backend='xvfb', use_xauth=True) + disp.start() + gui._pyautogui_x11._display = Xlib.display.Display(os.environ['DISPLAY']) + + def __del__(self): + self.browser_config.terminate() + + def parse_position(self, position: str, max_value: int) -> int: + # Screen percentage + if position[-1] == '%': + return round(max_value * (int(position[:-1]) / 100)) + + # Absolute value in pixels + return int(position) + + # --- PUBLIC METHODS --- + def navigate(self, url: str): + self.browser_config.terminate() + self.browser_config.open(url) + self.sleep(str(self.browser_config.get_navigation_sleep_duration())) + + def click_position(self, x: str, y: str): + max_x, max_y = gui.size() + + gui.moveTo(self.parse_position(x, max_x), self.parse_position(y, max_y)) + gui.click() + + def click(self, el_id: str): + el_image_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), f'elements/{el_id}.png') + x, y = gui.locateCenterOnScreen(el_image_path) + self.click_position(str(x), str(y)) + + def write(self, text: str): + gui.write(text, interval=0.1) + + def press(self, key: str): + gui.press(key) + + def hold(self, key: str): + gui.keyDown(key) + + def release(self, key: str): + gui.keyUp(key) + + def hotkey(self, *keys: str): + gui.hotkey(*keys) + + def sleep(self, duration: str): + sleep(float(duration)) + + def screenshot(self, filename: str): + filename = f'{self.params.evaluation_configuration.project}-{self.params.mech_group}-{filename}-{type(self.browser_config).__name__}-{self.browser_config.version}.jpg' + filepath = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../../../logs/screenshots', filename) + gui.screenshot(filepath) diff --git a/bci/evaluations/custom/custom_evaluation.py b/bci/evaluations/custom/custom_evaluation.py index 66130646..e4e9ff5c 100644 --- a/bci/evaluations/custom/custom_evaluation.py +++ b/bci/evaluations/custom/custom_evaluation.py @@ -1,8 +1,9 @@ import logging import os -import textwrap +from typing import Optional from bci.browser.configuration.browser import Browser +from bci.browser.interaction.interaction import Interaction from bci.configuration import Global from bci.evaluations.collectors.collector import Collector, Type from bci.evaluations.evaluation_framework import EvaluationFramework @@ -16,7 +17,7 @@ class CustomEvaluationFramework(EvaluationFramework): def __init__(self): super().__init__() self.dir_tree = self.initialize_dir_tree() - self.tests_per_project = self.initialize_tests_and_url_queues(self.dir_tree) + self.tests_per_project = self.initialize_tests_and_interactions(self.dir_tree) @staticmethod def initialize_dir_tree() -> dict: @@ -45,7 +46,7 @@ def set_nested_value(d: dict, keys: list[str], value: dict): return dir_tree @staticmethod - def initialize_tests_and_url_queues(dir_tree: dict) -> dict: + def initialize_tests_and_interactions(dir_tree: dict) -> dict: experiments_per_project = {} page_folder_path = Global.custom_page_folder for project, experiments in dir_tree.items(): @@ -53,15 +54,29 @@ def initialize_tests_and_url_queues(dir_tree: dict) -> dict: project_path = os.path.join(page_folder_path, project) experiments_per_project[project] = {} for experiment in experiments: - url_queue = CustomEvaluationFramework.__get_url_queue(project, project_path, experiment) - experiments_per_project[project][experiment] = { - 'url_queue': url_queue, - 'runnable': CustomEvaluationFramework.is_runnable_experiment(project, experiment, dir_tree), - } + data = {} + + if interaction_script := CustomEvaluationFramework.__get_interaction_script(project_path, experiment): + data['script'] = interaction_script + elif url_queue := CustomEvaluationFramework.__get_url_queue(project, project_path, experiment): + data['url_queue'] = url_queue + + data['runnable'] = CustomEvaluationFramework.is_runnable_experiment(project, experiment, dir_tree, data) + + experiments_per_project[project][experiment] = data return experiments_per_project @staticmethod - def __get_url_queue(project: str, project_path: str, experiment: str) -> list[str]: + def __get_interaction_script(project_path: str, experiment: str) -> list[str] | None: + interaction_file_path = os.path.join(project_path, experiment, 'script.cmd') + if os.path.isfile(interaction_file_path): + # If an interaction script is specified, it is parsed and used + with open(interaction_file_path) as file: + return file.readlines() + return None + + @staticmethod + def __get_url_queue(project: str, project_path: str, experiment: str) -> Optional[list[str]]: url_queue_file_path = os.path.join(project_path, experiment, 'url_queue.txt') if os.path.isfile(url_queue_file_path): # If an URL queue is specified, it is parsed and used @@ -77,12 +92,16 @@ def __get_url_queue(project: str, project_path: str, experiment: str) -> list[st f'https://{domain}/{project}/{experiment}/main', 'https://a.test/report/?bughog_sanity_check=OK', ] - raise AttributeError(f"Could not infer url queue for experiment '{experiment}' in project '{project}'") + return None @staticmethod - def is_runnable_experiment(project: str, poc: str, dir_tree: dict[str,dict]) -> bool: + def is_runnable_experiment(project: str, poc: str, dir_tree: dict[str,dict], data: dict[str,str]) -> bool: + # Always runnable if there is either an interaction script or url_queue present + if 'script' in data or 'url_queue' in data: + return True + + # Should have exactly one main folder otherwise domains = dir_tree[project][poc] - # Should have exactly one main folder main_paths = [paths for paths in domains.values() if paths is not None and 'main' in paths.keys()] if len(main_paths) != 1: return False @@ -101,12 +120,22 @@ def perform_specific_evaluation(self, browser: Browser, params: TestParameters) is_dirty = False try: - url_queue = self.tests_per_project[params.evaluation_configuration.project][params.mech_group]['url_queue'] - for url in url_queue: + experiment = self.tests_per_project[params.evaluation_configuration.project][params.mech_group] + + max_tries = 3 + if 'script' in experiment: + interaction = Interaction(browser, experiment['script'], params) tries = 0 - while tries < 3: + while tries < max_tries: tries += 1 - browser.visit(url) + interaction.execute() + else: + url_queue = experiment['url_queue'] + for url in url_queue: + tries = 0 + while tries < max_tries: + tries += 1 + browser.visit(url) except Exception as e: logger.error(f'Error during test: {e}', exc_info=True) is_dirty = True @@ -140,15 +169,22 @@ def get_projects(self) -> list[str]: def get_poc_structure(self, project: str, poc: str) -> dict: return self.dir_tree[project][poc] + def _get_poc_file_path(self, project: str, poc: str, domain: str, path: str, file_name: str) -> str: + # Top-level config file + if domain == 'Config' and path == '_': + return os.path.join(Global.custom_page_folder, project, poc, file_name) + + return os.path.join(Global.custom_page_folder, project, poc, domain, path, file_name) + def get_poc_file(self, project: str, poc: str, domain: str, path: str, file_name: str) -> str: - file_path = os.path.join(Global.custom_page_folder, project, poc, domain, path, file_name) + file_path = self._get_poc_file_path(project, poc, domain, path, file_name) if os.path.isfile(file_path): with open(file_path) as file: return file.read() raise AttributeError(f"Could not find PoC file at expected path '{file_path}'") def update_poc_file(self, project: str, poc: str, domain: str, path: str, file_name: str, content: str) -> bool: - file_path = os.path.join(Global.custom_page_folder, project, poc, domain, path, file_name) + file_path = self._get_poc_file_path(project, poc, domain, path, file_name) if os.path.isfile(file_path): if content == '': logger.warning('Attempt to save empty file ignored') @@ -185,32 +221,37 @@ def add_page(self, project: str, poc: str, domain: str, path: str, file_type: st headers_file_path = os.path.join(page_path, 'headers.json') if not os.path.exists(headers_file_path): with open(headers_file_path, 'w') as file: - file.write( - textwrap.dedent( - """\ - [ - { - "key": "Header-Name", - "value": "Header-Value" - } - ] - """ - ) - ) + file.write(self.get_default_file_content('headers.json')) self.sync_with_folders() # Notify clients of change (an experiment might now be runnable) Clients.push_experiments_to_all() return True + def add_config(self, project: str, poc: str, type: str) -> bool: + content = self.get_default_file_content(type) + + if (content == ''): + return False + + file_path = os.path.join(Global.custom_page_folder, project, poc, type) + with open(file_path, 'w') as file: + file.write(content) + + self.sync_with_folders() + # Notify clients of change (an experiment might now be runnable) + Clients.push_experiments_to_all() + + return True + @staticmethod def get_default_file_content(file_type: str) -> str: - if file_type != 'py': - return '' + path = os.path.join(os.path.dirname(os.path.realpath(__file__)), f'default_files/{file_type}') - with open('experiments/pages/Support/PythonServer/a.test/server.py', 'r') as file: - template_content = file.read() + if not os.path.exists(path): + return '' - return template_content + with open(path, 'r') as file: + return file.read() @staticmethod def include_file_headers(file_type: str) -> bool: @@ -218,5 +259,5 @@ def include_file_headers(file_type: str) -> bool: def sync_with_folders(self): self.dir_tree = self.initialize_dir_tree() - self.tests_per_project = self.initialize_tests_and_url_queues(self.dir_tree) + self.tests_per_project = self.initialize_tests_and_interactions(self.dir_tree) logger.info('Framework is synced with folders') diff --git a/bci/evaluations/custom/default_files/headers.json b/bci/evaluations/custom/default_files/headers.json new file mode 100644 index 00000000..81975b3f --- /dev/null +++ b/bci/evaluations/custom/default_files/headers.json @@ -0,0 +1,6 @@ +[ + { + "key": "Header-Name", + "value": "Header-Value" + } +] \ No newline at end of file diff --git a/bci/evaluations/custom/default_files/html b/bci/evaluations/custom/default_files/html new file mode 100644 index 00000000..2af82326 --- /dev/null +++ b/bci/evaluations/custom/default_files/html @@ -0,0 +1,10 @@ + + +
+ + + + + + + \ No newline at end of file diff --git a/bci/evaluations/custom/default_files/js b/bci/evaluations/custom/default_files/js new file mode 100644 index 00000000..664997c1 --- /dev/null +++ b/bci/evaluations/custom/default_files/js @@ -0,0 +1 @@ +// TODO - implement your PoC \ No newline at end of file diff --git a/bci/evaluations/custom/default_files/py b/bci/evaluations/custom/default_files/py new file mode 100644 index 00000000..91e823b8 --- /dev/null +++ b/bci/evaluations/custom/default_files/py @@ -0,0 +1,15 @@ +from flask import Request + +# Make sure that your page directory starts with 'py-' + +def main(req: Request): + # TODO - implement your functionality and return a Flask response + + return { + "agent": req.headers.get("User-Agent"), + "cookies": req.cookies, + "host": req.host, + "path": req.path, + "scheme": req.scheme, + "url": req.url + } \ No newline at end of file diff --git a/bci/evaluations/custom/default_files/script.cmd b/bci/evaluations/custom/default_files/script.cmd new file mode 100644 index 00000000..749e7a21 --- /dev/null +++ b/bci/evaluations/custom/default_files/script.cmd @@ -0,0 +1,12 @@ +# TODO - add your interaction script using the commands + +# NAVIGATE url +# CLICK_POSITION x y where x and y are absolute numbers or screen percentages +# CLICK element_id where element_id is one of one, two, three, four, five, six +# WRITE text +# PRESS key +# HOLD key +# RELEASE key +# HOTKEY key1 key2 ... +# SLEEP seconds where seconds is a float or an int +# SCREENSHOT file_name diff --git a/bci/evaluations/custom/default_files/url_queue.txt b/bci/evaluations/custom/default_files/url_queue.txt new file mode 100644 index 00000000..8918d8d1 --- /dev/null +++ b/bci/evaluations/custom/default_files/url_queue.txt @@ -0,0 +1,2 @@ +TODO - add your URLs to visit +https://a.test/report/?bughog_sanity_check=OK \ No newline at end of file diff --git a/bci/main.py b/bci/main.py index 127c1c29..fddb53ae 100644 --- a/bci/main.py +++ b/bci/main.py @@ -155,6 +155,10 @@ def get_available_domains() -> list[str]: @staticmethod def add_page(project: str, poc: str, domain: str, path: str, file_type: str) -> bool: return Main.master.evaluation_framework.add_page(project, poc, domain, path, file_type) + + @staticmethod + def add_config(project: str, poc: str, type: str) -> bool: + return Main.master.evaluation_framework.add_config(project, poc, type) @staticmethod def sigint_handler(signum, frame): diff --git a/bci/web/blueprints/api.py b/bci/web/blueprints/api.py index 1828c526..accc66d4 100644 --- a/bci/web/blueprints/api.py +++ b/bci/web/blueprints/api.py @@ -238,6 +238,20 @@ def add_page(project: str, poc: str): } +@api.route('/poc/