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///config', methods=['POST']) +def add_config(project: str, poc: str): + data = request.json.copy() + success = bci_api.add_config(project, poc, data['type']) + if success: + return { + 'status': 'OK' + } + else: + return { + 'status': 'NOK' + } + + @api.route('/poc/domain/', methods=['GET']) def get_available_domains(): return { diff --git a/bci/web/vue/src/components/poc-editor.vue b/bci/web/vue/src/components/poc-editor.vue index 278e1189..167e0175 100644 --- a/bci/web/vue/src/components/poc-editor.vue +++ b/bci/web/vue/src/components/poc-editor.vue @@ -4,6 +4,7 @@ import { Mode as JsMode } from 'ace-builds/src-noconflict/mode-javascript'; import { Mode as JsonMode } from 'ace-builds/src-noconflict/mode-json'; import { Mode as PyMode } from 'ace-builds/src-noconflict/mode-python'; + import { getMode as getInteractionScriptMode } from '../interaction_script_mode'; import 'ace-builds/src-min-noconflict/ext-modelist'; import 'ace-builds/src-min-noconflict/theme-twilight'; import 'ace-builds/src-min-noconflict/theme-xcode'; @@ -30,13 +31,24 @@ // Example tree: // { // 'test.com': { - // "path1": ["file1", "file2"], - // "path2": ["file1"], + // "path1": { + // "file1": null, + // "file2": null + // }, + // "path2": { + // "file1": null + // }, // }, // 'not.test': { - // "path1": ["file1", "file2"], - // "path2": ["file1"], + // "path1": { + // "file1": null, + // "file2": null + // }, + // "path2": { + // "file1": null + // }, // }, + // 'script.cmd': null, // }, }, available_file_types: [ @@ -44,6 +56,14 @@ 'js', 'py', ], + available_config_types: { + 'script.cmd': 'Interaction script', + 'url_queue.txt': 'URL queue' + }, + poc_tree_config: { + domain: 'Config', + page: '_', + }, dialog: { domain: { name: null, @@ -55,10 +75,38 @@ type: null, } }, + config_type: null, editor: null, timeout: null, } }, + computed: { + active_poc_tree() { + const configDomain = this.poc_tree_config.domain; + const configPage = this.poc_tree_config.page; + + return Object.entries(this.active_poc.tree).reduce((acc, [domain, pages]) => { + if (domain !== pages) { + return [ + ...acc, + [domain, pages] + ] + } + + // A single top-level file -> create a virtual config folder + const config_folder = acc.find(([domain]) => domain === configDomain); + return [ + ...acc.filter(([domain]) => domain !== configDomain), + [configDomain, { + [configPage]: { + ...config_folder[1].subfolder, + [domain]: pages, + } + }], + ] + }, [[configDomain, {[configPage]: {}}]]); + } + }, methods: { set_active_file(domain, file_path, file_name) { this.active_poc.active_domain = domain; @@ -158,6 +206,9 @@ case "py": this.editor.session.setMode(new PyMode()); break; + case "cmd": + getInteractionScriptMode().then(({ Mode }) => { this.editor.session.setMode(new Mode()) }) + break; } }, add_page() { @@ -178,6 +229,27 @@ .catch(() => { }); + }, + add_config() { + const url = `/api/poc/${this.project}/${this.poc}/config`; + axios.post(url, { + "type": this.config_type, + }) + .then(() => { + this.update_poc_tree(); + this.config_type = null; + }) + .catch(() => { + + }); + }, + has_config() { + if (this.active_poc.tree === undefined) { + console.error("Could not check config in undefined poc tree"); + return false; + } + const arr = Object.keys(this.active_poc.tree).filter((domain) => ['url_queue.txt', 'script.cmd'].includes(domain)); + return arr.length > 0; } }, mounted() { @@ -215,17 +287,12 @@ -
  • - Add page -
  • +
    +
  • + Add page +
  • +
  • + Add script +
  • +
    @@ -309,4 +381,24 @@ + + + +
    +

    +

    Choose config type:
    + + +

    +
    + + +
    +
    +
    diff --git a/bci/web/vue/src/interaction_script_mode.js b/bci/web/vue/src/interaction_script_mode.js new file mode 100644 index 00000000..4413d521 --- /dev/null +++ b/bci/web/vue/src/interaction_script_mode.js @@ -0,0 +1,47 @@ +const KEYWORDS = "NAVIGATE|CLICK_POSITION|CLICK|WRITE|PRESS|HOLD|RELEASE|HOTKEY|SLEEP|SCREENSHOT"; + +ace.define("ace/mode/interaction_script_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/text_highlight_rules"], function(require, exports, module){"use strict"; + const oop = require("../lib/oop"); + const TextHighlightRules = require("./text_highlight_rules").TextHighlightRules; + + const HighlightRules = function() { + + var keywordMapper = this.createKeywordMapper({ + "keyword": KEYWORDS, + }, "argument", true); + + this.$rules = { + "start" : [ { + token : "comment", + regex : "#.*$" + }, { + token : "constant.numeric", // float + regex : "[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b" + }, { + token : keywordMapper, + regex : "[a-zA-Z_$][a-zA-Z0-9_$]*\\b" + } ] + }; + this.normalizeRules(); + }; + + oop.inherits(HighlightRules, TextHighlightRules); + exports.HighlightRules = HighlightRules; +}); + +ace.define("ace/mode/interaction_script",["require","exports","module","ace/lib/oop","ace/mode/text", "ace/mode/interaction_script_highlight_rules"], function(require, exports){"use strict"; + const oop = require("ace/lib/oop"); + const TextMode = require("ace/mode/text").Mode; + const HighlightRules = require("ace/mode/interaction_script_highlight_rules").HighlightRules; + const Mode = function() { + this.HighlightRules = HighlightRules; + }; + oop.inherits(Mode, TextMode); + exports.Mode = Mode; +}); + +const getMode = () => new Promise((resolve) => ace.require(["ace/mode/interaction_script"], resolve)); + +export { + getMode, +}; \ No newline at end of file diff --git a/bci/web/vue/src/main.js b/bci/web/vue/src/main.js index 70ff98b0..121570e3 100644 --- a/bci/web/vue/src/main.js +++ b/bci/web/vue/src/main.js @@ -5,8 +5,8 @@ import 'flowbite' import 'axios' import { OhVueIcon, addIcons } from "oh-vue-icons"; -import { MdInfooutline, FaRegularEdit, FaLink } from "oh-vue-icons/icons"; +import { MdInfooutline, FaRegularEdit, FaLink, FaPlus } from "oh-vue-icons/icons"; -addIcons(MdInfooutline, FaRegularEdit, FaLink); +addIcons(MdInfooutline, FaRegularEdit, FaLink, FaPlus); const app = createApp(App); app.component("v-icon", OhVueIcon).mount('#app') diff --git a/bci/web/vue/src/style.css b/bci/web/vue/src/style.css index 713de6c3..c52649c6 100644 --- a/bci/web/vue/src/style.css +++ b/bci/web/vue/src/style.css @@ -3,7 +3,7 @@ @tailwind utilities; .button { - @apply text-white bg-blue-700 hover:bg-blue-800 active:ring-4 focus:outline-none font-medium rounded-lg text-sm px-4 py-2.5 text-center items-center dark:hover:bg-blue-700 dark:focus:ring-blue-800 + @apply text-white bg-blue-700 hover:bg-blue-800 active:ring-4 focus:outline-none cursor-pointer font-medium rounded-lg text-sm px-4 py-2.5 text-center items-center dark:hover:bg-blue-700 dark:focus:ring-blue-800 } .dropdown-head { @@ -208,6 +208,12 @@ button:focus-visible { outline: 4px auto -webkit-focus-ring-color; } +button.no-style { + padding: 0; + background: transparent; + border: none; +} + .card { padding: 2em; } diff --git a/experiments/pages/Support/AutoGUI/a.test/main/headers.json b/experiments/pages/Support/AutoGUI/a.test/main/headers.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/experiments/pages/Support/AutoGUI/a.test/main/headers.json @@ -0,0 +1 @@ +[] diff --git a/experiments/pages/Support/AutoGUI/a.test/main/index.html b/experiments/pages/Support/AutoGUI/a.test/main/index.html new file mode 100644 index 00000000..93eb4fc7 --- /dev/null +++ b/experiments/pages/Support/AutoGUI/a.test/main/index.html @@ -0,0 +1,14 @@ + + + + + + + +
    + + + +
    + + \ No newline at end of file diff --git a/experiments/pages/Support/AutoGUI/script.cmd b/experiments/pages/Support/AutoGUI/script.cmd new file mode 100644 index 00000000..f72b0eff --- /dev/null +++ b/experiments/pages/Support/AutoGUI/script.cmd @@ -0,0 +1,18 @@ +NAVIGATE https://a.test/Support/AutoGUI/main + +SCREENSHOT click1 +CLICK one +WRITE AutoGUI +HOTKEY ctrl a +HOTKEY ctrl c + +SCREENSHOT click2 +CLICK two + +# Equivalent to HOTKEY ctrl v +HOLD ctrl +HOLD v +RELEASE v +RELEASE ctrl + +PRESS Enter diff --git a/experiments/pages/Support/PythonServer/a.test/server.py b/experiments/pages/Support/PythonServer/a.test/server.py index fb47ecdb..9e364eaa 100644 --- a/experiments/pages/Support/PythonServer/a.test/server.py +++ b/experiments/pages/Support/PythonServer/a.test/server.py @@ -1,10 +1,10 @@ from flask import Request -# Make sure that your page directory starts with 'py-' +# Make sure that your file ends 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, diff --git a/experiments/pages/Support/test/interaction_script.cmd b/experiments/pages/Support/test/interaction_script.cmd new file mode 100644 index 00000000..749e7a21 --- /dev/null +++ b/experiments/pages/Support/test/interaction_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/experiments/res/bughog.css b/experiments/res/bughog.css new file mode 100644 index 00000000..8fe70435 --- /dev/null +++ b/experiments/res/bughog.css @@ -0,0 +1,63 @@ +#fullscreen, #one, #two, #three, #four, #five, #six { + position: fixed; + z-index: 10; + font-size: 1px; + border: none; + outline: none; + resize: none; +} + +#fullscreen { + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: red; +} + +#one, #two, #three, #four, #five, #six { + width: 25px; + height: 20px; +} + +#one { + background-color: #800000; + color: #800000; + top: 10px; + left: 10px; +} + +#two { + background-color: #dcbeff; + color: #dcbeff; + top: 70px; + left: 10px; +} + +#three { + background-color: #ffe119; + color: #ffe119; + top: 130px; + left: 10px; +} + +#four { + background-color: #4363d8; + color: #4363d8; + top: 10px; + left: 85px; +} + +#five { + background-color: #f58231; + color: #f58231; + top: 70px; + left: 85px; +} + +#six { + background-color: #000075; + color: #000075; + top: 130px; + left: 85px; +} \ No newline at end of file diff --git a/logs/screenshots/.gitkeep b/logs/screenshots/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/nginx/config/experiments.conf b/nginx/config/experiments.conf index 9ac74009..01c40960 100644 --- a/nginx/config/experiments.conf +++ b/nginx/config/experiments.conf @@ -11,7 +11,7 @@ location = / { # Shared static resources location ^~ /res/ { include /etc/nginx/config/notify_collector.conf; - error_page 504 @resources; + error_page 504 =200 @resources; } location @resources { diff --git a/requirements.in b/requirements.in index b7055ad5..f0993275 100644 --- a/requirements.in +++ b/requirements.in @@ -5,3 +5,7 @@ flatten-dict gunicorn pymongo requests +pyautogui +pyvirtualdisplay +Pillow +Xlib \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d2968cc8..2a367b96 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile requirements.in @@ -38,10 +38,34 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug +mouseinfo==0.1.3 + # via pyautogui packaging==24.1 # via gunicorn +pillow==11.0.0 + # via -r requirements.in +pyautogui==0.9.54 + # via -r requirements.in +pygetwindow==0.0.9 + # via pyautogui pymongo==4.10.1 # via -r requirements.in +pymsgbox==1.0.9 + # via pyautogui +pyperclip==1.9.0 + # via mouseinfo +pyrect==0.2.0 + # via pygetwindow +pyscreeze==1.0.1 + # via pyautogui +python3-xlib==0.15 + # via + # mouseinfo + # pyautogui +pytweening==1.2.0 + # via pyautogui +pyvirtualdisplay==3.0 + # via -r requirements.in requests==2.32.3 # via # -r requirements.in @@ -49,7 +73,9 @@ requests==2.32.3 simple-websocket==1.1.0 # via flask-sock six==1.16.0 - # via flatten-dict + # via + # flatten-dict + # xlib urllib3==2.2.3 # via # docker @@ -58,3 +84,5 @@ werkzeug==3.0.4 # via flask wsproto==1.2.0 # via simple-websocket +xlib==0.21 + # via -r requirements.in