Sort of working beta

This commit is contained in:
2025-02-06 17:30:45 +01:00
parent 5f42c707eb
commit 2ef114e154
97 changed files with 3093 additions and 106 deletions

69
.dockerignore Normal file
View File

@ -0,0 +1,69 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
# Database
database/database.sqlite
**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
# **/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
/.phpunit.cache
/node_modules
# Is built later on
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
.env
.env.backup
.env.production
.env.example
.phpactor.json
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/auth.json
/.fleet
/.idea
/.nova
/.vscode
/.zed
# Browser
app/Browser/console/**
app/Browser/screenshots/**
app/Browser/source/**

69
.env.docker Normal file
View File

@ -0,0 +1,69 @@
APP_NAME=DatBrowser
APP_ENV=production
APP_KEY=base64:O3G9GpqYI7ZkAvkffIiS+5oodFGe/yAQ/uIcSIqU3NU=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://127.0.0.1
APP_LOCALE=fr
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=datbrowser
DB_USERNAME=datboi
DB_PASSWORD=IMDATBOI
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
# REDIS_CLIENT=phpredis
# REDIS_HOST=127.0.0.1
# REDIS_PASSWORD=null
# REDIS_PORT=6379
# MAIL_MAILER=log
# MAIL_SCHEME=null
# MAIL_HOST=127.0.0.1
# MAIL_PORT=2525
# MAIL_USERNAME=null
# MAIL_PASSWORD=null
# MAIL_FROM_ADDRESS="hello@example.com"
# MAIL_FROM_NAME="${APP_NAME}"
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# AWS_DEFAULT_REGION=us-east-1
# AWS_BUCKET=
# AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
VITE_APP_URL="${APP_URL}"
DUSK_DRIVER_URL="http://undetected-chromedriver:4444"

View File

@ -3,9 +3,9 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost:8000
APP_URL=http://127.0.0.1:8000
APP_LOCALE=en
APP_LOCALE=fr
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
@ -43,24 +43,26 @@ CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# REDIS_CLIENT=phpredis
# REDIS_HOST=127.0.0.1
# REDIS_PASSWORD=null
# REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# MAIL_MAILER=log
# MAIL_SCHEME=null
# MAIL_HOST=127.0.0.1
# MAIL_PORT=2525
# MAIL_USERNAME=null
# MAIL_PASSWORD=null
# MAIL_FROM_ADDRESS="hello@example.com"
# MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# AWS_DEFAULT_REGION=us-east-1
# AWS_BUCKET=
# AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
DUSK_DRIVER_URL="http://127.0.0.1:4444"

View File

@ -0,0 +1,23 @@
name: Push Image to registry
on:
push:
branches:
- master
jobs:
build-image:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: ${REGISTRY}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
run: |
docker build -t ${REGISTRY}/${REPO_OWNER}/${IMAGE_NAME}:latest .
docker push ${REGISTRY}/${REPO_OWNER}/${IMAGE_NAME}:latest

4
.gitignore vendored
View File

@ -21,3 +21,7 @@ yarn-error.log
/.nova
/.vscode
/.zed
# Browser
app/Browser/console
app/Browser/screenshots
app/Browser/source

86
Dockerfile Normal file
View File

@ -0,0 +1,86 @@
# INSTALL PHP COMPOSER DEPENDENCIES
FROM composer:lts AS composer-deps
WORKDIR /
# If your composer.json file defines scripts that run during dependency installation and
# reference your application source files, uncomment the line below to copy all the files
# into this layer.
# COPY composer.json composer.lock ./
COPY . .
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a bind mounts to composer.json and composer.lock to avoid having to copy them
# into this layer.
# Leverage a cache mount to /tmp/cache so that subsequent builds don't have to re-download packages.
RUN --mount=type=bind,source=composer.json,target=composer.json \
--mount=type=bind,source=composer.lock,target=composer.lock \
--mount=type=cache,target=/tmp/cache \
composer install --no-interaction --prefer-dist
# ========================================
# BUILD VUE APP
FROM node:20 AS build-vue
WORKDIR /usr/app
# COPY package.json tsconfig.json tailwind.config.js vite.config.js ./
# COPY resources/ ./resources/
COPY . .
RUN mv .env.docker .env
COPY --from=composer-deps /vendor/ ./vendor
RUN mkdir -p public/build/ && npm i && npm run build
# ========================================
# RUN
FROM php:8.2-alpine AS final
# Install system dependencies
RUN apk update && apk add --no-cache \
git \
curl \
libpng-dev \
libxml2-dev \
libzip-dev \
zip \
unzip \
supervisor \
nginx \
openssl \
linux-headers
RUN docker-php-ext-configure zip && docker-php-ext-install zip
RUN docker-php-ext-install gd pdo pdo_mysql zip
# Get latest Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www
COPY . .
COPY --from=build-vue /usr/app/public/build ./public/build
RUN mv .env.docker .env
# DUSK
# RUN php artisan dusk:install && php artisan dusk:chrome-driver && mv ./undetectedChromedriver/chromedriver-linux ./vendor/laravel/dusk/bin/chromedriver-linux
# RUN php artisan dusk:install && php artisan dusk:chrome-driver
# RUN php artisan dusk:install
RUN composer install --no-interaction --prefer-dist
# Link the storage directory to the public directory.
RUN php artisan storage:link
# Laravel optimization commands
# RUN php artisan cache:clear
RUN php artisan config:cache && php artisan route:cache
RUN chmod +x ./dockerEntryPoint.sh
EXPOSE 80
CMD ["./dockerEntryPoint.sh"]

272
app/Browser/BrowserJob.php Normal file
View File

@ -0,0 +1,272 @@
<?php
namespace App\Browser;
use App\Browser\JobArtifacts\JobRunArtifact;
use App\Exception\JobException;
use App\Models\JobArtifact;
use App\Models\JobRun;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\RemoteWebElement;
use Facebook\WebDriver\WebDriverBy;
use Illuminate\Support\Collection;
use Laravel\Dusk\Browser;
use Laravel\Dusk\TestCase as BaseTestCase;
use Closure;
use PHPUnit\Framework\Attributes\BeforeClass;
use Exception;
use Laravel\Dusk\Chrome\SupportsChrome;
use Laravel\Dusk\Concerns\ProvidesBrowser;
use Laravel\Dusk;
use Throwable;
abstract class BrowserJob
{
use SupportsChrome, ProvidesBrowser;
public int $jobId;
public function __construct(int $jobId)
{
$this->jobId = $jobId;
}
/**
* Execute the callback in a browser
* @param Closure $callback function with a Browser as parameter
* @return void
*/
private function executeInBrowser(Closure $callback): ?JobRun
{
$this->prepare();
$this->setUp();
$this->browse(function (Browser $browser) use ($callback, &$log) {
try {
$log = $callback($browser);
// } catch (Exception $e) {
// $browser->screenshot("failure-{$this->jobId}");
// dump($e);
// throw $e;
}
catch (Throwable $e) {
$browser->screenshot("failure-{$this->jobId}");
dump($e);
throw $e;
} finally {
$browser->quit();
return [];
}
});
return $log;
}
/**
* Execute the job
* @return void
*/
public function execute(): ?JobRun {
return $this->executeInBrowser(function (Browser $browser): ?JobRun {
return $this->run($browser);
});
}
/**
* Execute the job test
* @return void
*/
public function executeTest(): ?JobRun {
return $this->executeInBrowser(function (Browser $browser): ?JobRun {
return $this->runTest($browser);
});
}
/**
* Steps that run in the browser
* @param \Laravel\Dusk\Browser $browser
* @return void
*/
abstract public function run(Browser $browser): ?JobRun;
abstract public function runTest(Browser $browser): ?JobRun;
/**
* Prepare for Dusk test execution.
* @unused
*/
#[BeforeClass]
public static function prepare(): void
{
if (config("dusk.driver.url") == null && !(isset($_ENV['LARAVEL_SAIL']) && $_ENV['LARAVEL_SAIL'] == '1')) {
static::startChromeDriver(['--port=9515']);
}
}
/**
* Register the base URL with Dusk.
*
* @return void
*/
protected function setUp(): void
{
Browser::$baseUrl = "https://pdftools.matthiasg.dev/";
Browser::$storeScreenshotsAt = base_path('app/Browser/screenshots');
Browser::$storeConsoleLogAt = base_path('app/Browser/console');
Browser::$storeSourceAt = base_path('app/Browser/source');
/*Browser::$userResolver = function () {
return $this->user();
}; */
}
protected function makeSimpleJobRun(bool $success, string $name, string $content): JobRun {
$artifact = new JobRun([
"job_id" => $this->jobId,
"success" => $success
]);
$artifact->save();
$artifact->artifacts()->save(new JobArtifact([
"name" => $name,
"content" => $content,
]));
return $artifact;
}
/**
* Create the RemoteWebDriver instance.
*/
protected function driver(): RemoteWebDriver
{
$options = (new ChromeOptions)->addArguments(collect([
$this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1360,1020',
'--disable-search-engine-choice-screen',
'--disable-gpu',
'--no-sandbox',
'--disable-setuid-sandbox',
'--whitelisted-ips=""',
'--disable-dev-shm-usage',
'--user-data-dir=/home/seluser/profile/',
])->all());
return RemoteWebDriver::create(
config("dusk.driver.url", 'http://localhost:9515'),
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY,
$options
)
);
}
public function terminate() {
$this->browse(function (Browser $browser) {
$browser->quit();
});
}
/**
* Determine whether the Dusk command has disabled headless mode.
*/
protected function hasHeadlessDisabled(): bool
{
return isset($_SERVER['DUSK_HEADLESS_DISABLED']) ||
isset($_ENV['DUSK_HEADLESS_DISABLED']);
}
/**
* Determine if the browser window should start maximized.
*/
protected function shouldStartMaximized(): bool
{
return isset($_SERVER['DUSK_START_MAXIMIZED']) ||
isset($_ENV['DUSK_START_MAXIMIZED']);
}
/**
* Register an "after class" tear down callback.
*
* @param \Closure $callback
* @return void
*/
public static function afterClass(Closure $callback)
{
static::$afterClassCallbacks[] = $callback;
}
public static function name()
{
return "test";
}
public static function dataName()
{
return "dataTest";
}
// BROWSER MACROS
protected function waitForAndClickText(Browser $browser, string $text, int $timeout = 30, bool $ignoreCase = true) {
$browser->waitForText($text, $timeout, $ignoreCase);
$this->findElementContainingText($browser, $text, $ignoreCase)?->click();
}
protected function waitForElementContainingTextAndGetIt(Browser $browser, string $text, int $timeout = 30, bool $ignoreCase = true): RemoteWebElement|null {
try {
$browser->waitForText($text, $timeout, $ignoreCase);
return $this->findElementContainingText($browser, $text, $ignoreCase);
} catch (Exception $e) {
return null;
}
}
protected function findElementContainingText(Browser $browser, string $text, bool $ignoreCase = true): RemoteWebElement|null {
try {
if ($ignoreCase) {
return $browser->driver->findElement(WebDriverBy::xpath("//*[{$this->xpathContainsIgnoreCase($text)}]"));
} else {
return $browser->driver->findElement(WebDriverBy::xpath("//*[contains(text(), \"{$text}\")]"));
}
} catch (Exception $e) {
return null;
}
}
private function xpathContainsIgnoreCase(string $needle, string $haystack = "text()") {
$needle = strtolower($needle);
return "contains(translate({$haystack}, \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\", \"abcdefghijklmnopqrstuvwxyz\"), \"{$needle}\")";
}
protected function waitForAndClickElementContainingText(Browser $browser, string $elementXpath, string $text, int $timeout = 30, bool $ignoreCase = true) {
$browser->waitForText($text, $timeout, $ignoreCase);
$this->findElementContainingElementWithText($browser, $elementXpath, $text, $ignoreCase)?->click();
}
protected function waitForElementContainingElementWithTextAndGetIt(Browser $browser, string $elementXpath, string $text, int $timeout = 30, bool $ignoreCase = true): RemoteWebElement|null {
try {
$browser->waitForText($text, $timeout, $ignoreCase);
sleep(2);
return $this->findElementContainingElementWithText($browser, $elementXpath, $text, $ignoreCase);
} catch (Exception $e) {
return null;
}
}
protected function findElementContainingElementWithText(Browser $browser, string $elementXpath, string $text, bool $ignoreCase = true): RemoteWebElement|null {
try {
if ($ignoreCase) {
dump("{$elementXpath}[.//*[{$this->xpathContainsIgnoreCase($text)}]]");
return $browser->driver->findElement(WebDriverBy::xpath("{$elementXpath}[.//*[{$this->xpathContainsIgnoreCase($text)}]]"));
} else {
return $browser->driver->findElement(WebDriverBy::xpath("{$elementXpath}[.//*[contains(text(), \"{$text}\")]]"));
}
} catch (Exception $e) {
return null;
}
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Browser\Components\Hellcase;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Component as BaseComponent;
class MainNav extends BaseComponent
{
/**
* Get the root selector for the component.
*/
public function selector(): string
{
return 'header.header';
}
/**
* Assert that the browser page contains the component.
*/
public function assert(Browser $browser): void
{
$browser->assertVisible($this->selector());
}
/**
* Get the element shortcuts for the component.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@logo' => 'a.header-logo',
'@daily-free-link' => 'a[href="/dailyfree"]',
];
}
public function goToHome(Browser $browser) {
$browser->scrollIntoView('@logo');
$browser->click('@logo');
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Browser\Jobs\Hellcase;
use Laravel\Dusk\Browser;
use function rtrim;
class HellcaseDailyFreeScreenshot {
public const IMG_FILE_NAME = "Hellcase-dailyFreeLoot";
/**
* QR code validity in seconds
* @var int
*/
public const QR_CODE_VALIDITY = 20;
public static function getImgFileAbsolutePath(): string {
return rtrim(Browser::$storeScreenshotsAt, '/') . "/Hellcase/" . static::IMG_FILE_NAME;
}
public static function getImgFileProjectPath(): string {
return app_path("Browser/screenshots/Hellcase/" . static::IMG_FILE_NAME);
}
public static function getImgFileExternalPath(): string {
return "screenshots/Hellcase/" . static::IMG_FILE_NAME;
}
}

View File

@ -0,0 +1,315 @@
<?php
namespace App\Browser\Jobs\Hellcase;
use App\Browser\BrowserJob;
use App\Browser\Components\Hellcase\MainNav;
use App\Models\JobArtifact;
use App\Browser\JobArtifacts\JobRunArtifact;
use App\Browser\Jobs\Hellcase\HellcaseLoginQrCode;
use App\Models\JobRun;
use App\Notification\NotificationBody\Hellcase\HellcaseNotificationDailyFreeBody;
use App\Notification\NotificationBody\Hellcase\HellcaseNotificationLoginBody;
use App\Notification\Notifications\Hellcase\HellcaseNotificationDailyFree;
use App\Notification\Notifications\Hellcase\HellcaseNotificationLogin;
use App\Notification\Providers\AllNotification;
use Dom\XPath;
use Facebook\WebDriver\WebDriverBy;
use Laravel\Dusk\Browser;
class HellcaseJob extends BrowserJob
{
private const STEAM_LOGIN_THRESHOLD = 5 * 60; // 5 minutes
private JobRun $jobRun;
public function __construct()
{
parent::__construct(2);
}
public function run(Browser $browser): ?JobRun
{
$this->jobRun = new JobRun([
"job_id" => $this->jobId,
"success" => false,
]);
$this->jobRun->save();
$browser->visit('https://hellcase.com');
sleep(5);
$this->removePopups($browser);
sleep(5);
$this->signin($browser);
$this->joinFreeGiveaways($browser);
$this->getDailyFree($browser);
$this->jobRun->success = true;
$this->jobRun->save();
return $this->jobRun;
}
/**
* @inheritDoc
*/
public function runTest(Browser $browser): ?JobRun
{
try {
$browser->visit('https://hellcase.com');
sleep(5);
$this->removePopups($browser);
$this->signin($browser);
return $this->makeSimpleJobRun(
true,
"Connexion réussie",
"Datboi a réussi à se connecter sur Hellcase"
);
} catch (\Exception $e) {
return $this->makeSimpleJobRun(
true,
"Connexion échouée",
"Datboi n'a pas réussi à se connecter sur Hellcase :\n" . $e->getMessage()
);
}
}
private function signin(Browser $browser)
{
try {
$browser->clickAtXPath('//button[.//span[text() = "Sign in"]]');
} catch (\Exception $e) {
return;
}
sleep(3);
$browser->clickAtXPath('//button[.//span[contains(text(), "Sign in through Steam")]]');
sleep(5);
// QR CODE SCANNING
try {
$browser->waitForTextIn("div", "Or sign in with QR", 30, true);
$qrCode = $browser->driver->findElement(WebDriverBy::xpath('//div[./*[contains(text(), "Or sign in with QR")]]'));
// Wait to be redirected to the Steam login page, while waiting take a new screenshot every 30 seconds
$isBackOnHellcase = false;
$secondsCounter = 0;
while (!$isBackOnHellcase && $secondsCounter < self::STEAM_LOGIN_THRESHOLD) {
// Take a screenshot of the QR code and send it
$qrCode->takeElementScreenshot(HellcaseLoginQrCode::getImgFileAbsolutePath());
AllNotification::send(
new HellcaseNotificationLogin(
$this->jobId,
new HellcaseNotificationLoginBody()
)
);
try {
$browser->waitForLocation("https://hellcase.com", HellcaseLoginQrCode::QR_CODE_VALIDITY); // The QR code is only valid for 20 seconds
} catch (\Exception $e) {
$secondsCounter += HellcaseLoginQrCode::QR_CODE_VALIDITY; // we've waited for QR_CODE_VALIDITY seconds
continue;
}
$isBackOnHellcase = true;
}
} catch (\Exception $e) {
// If the QR code is not found, we are not on the QR code page
$isBackOnHellcase = true;
} catch (\Throwable $e) {
// If the QR code is not found, we are not on the QR code page
$isBackOnHellcase = true;
}
if ($isBackOnHellcase) {
// Click a button tjat says "sign in"
$browser->waitForText("By signing into steam.loginhell.com through Steam", 30, true);
$browser->clickAtXPath('//input[@id = "imageLogin"]');
sleep(30);
}
}
private function joinFreeGiveaways(Browser $browser)
{
try {
$buttons = $browser->driver->findElements(WebDriverBy::xpath('//a[text() = "Join for free"]'));
} catch (\Exception $e) {
return;
}
if (sizeof($buttons) == 0) {
$this->jobRun->addArtifact(new JobArtifact([
"name" => "Pas de concours joignable",
"content" => ""
]));
}
foreach ($buttons as $button) {
$button->click();
sleep(5);
$this->joinGiveaway($browser);
$browser->within(new MainNav, function (Browser $browser) {
$browser->goToHome();
});
}
}
private function joinGiveaway(Browser $browser)
{
$joinButton = $browser->driver->findElement(WebDriverBy::xpath('//button[span[contains(text(), "Join for free")]]'));
$joinButton->click();
}
private function getDailyFree(Browser $browser)
{
$browser->visit('https://hellcase.com/dailyfree');
$browser->waitForText("Get Daily free loot", 30, true);
// Do we fill the conditions ?
if (sizeof(value: $browser->driver->findElements(WebDriverBy::xpath('//p[contains(text(), "Fulfill the conditions below")]'))) > 0) {
$this->fillDailyFreeConditions($browser);
}
// If we see "availible in 20 HR 49 MIN", parse the hours and minute and reschedule
$availibleInButton = $this->waitForElementContainingTextAndGetIt($browser, "available", 30);
if ($availibleInButton != null) {
$hours = $availibleInButton->getText();
$hours = explode(" ", $hours);
$minutes = $hours[2];
$hours = $hours[0];
// $this->reschedule($hours);
$this->jobRun->addArtifact(new JobArtifact([
"name" => "Cadeau gratuit pas encore disponible",
"content" => "Le cadeau gratuit journalier sera disponible dans $hours heures et $minutes minutes.\nDatboi se fera un plaisir d'aller le chercher pour vous."
]));
return;
}
$this->waitForAndClickText($browser, "Get Free Bonus", 30, true);
$lootElement = $browser->driver->findElement(WebDriverBy::xpath('//div[contains(@class, "daily-free-win-bonus")]'));
$lootElement->takeElementScreenshot(HellcaseDailyFreeScreenshot::getImgFileAbsolutePath());
AllNotification::send(
new HellcaseNotificationDailyFree($this->jobId, new HellcaseNotificationDailyFreeBody())
);
sleep(5000);
}
/**
* Must be on the dailyfree page
* @param \Laravel\Dusk\Browser $browser
* @throws \Exception
* @return void
*/
private function fillDailyFreeConditions(Browser $browser) {
// 1. See what conditions we need to fullfill
$conditions = [];
$conditionsDivs = $browser->driver->findElements(WebDriverBy::xpath('//*[@class = "daily-free-requirement__heading-left"]'));
for($i = 0; $i < sizeof($conditionsDivs); $i++) {
$conditionDiv = $conditionsDivs[$i];
// See if the element has the completed class
$conditions[$i] = [
"isFilled" => str_contains($conditionDiv->getAttribute("class"), "completed"),
"text" => $conditionDiv->getText()
];
}
if (sizeof($conditions) == 0) {
throw new \Exception("No dailyfree conditions found");
}
if (!$conditions[0]["isFilled"]) {
$this->changeSteamProfilePicture($browser);
}
if (!$conditions[1]["isFilled"]) {
$this->changeSteamProfileToPublic($browser);
}
}
/**
*
* Must be on the dailyfree page
* @param \Laravel\Dusk\Browser $browser
* @return void
*/
private function changeSteamProfilePicture(Browser $browser) {
// Get all of the availible image link
$images = $browser->driver->findElements(WebDriverBy::xpath('//a[@class = "daily-free-user-requirement-avatar-item"]'));
// Download the image from the second link in a special folder
$imageLink = $images[1]->getAttribute("href");
// Download the image in app/Browser/downloads/
$imagePath = base_path("app/Browser/downloads/Hellcase/pp.jpg");
file_put_contents($imagePath, file_get_contents($imageLink));
$this->goToSteamProfileSettings($browser);
// Wait for and click "Avatar"
$this->waitForAndClickText($browser, "Avatar");
// Wait for and click "Upload your avatar"
$browser->waitForText("Upload your avatar", 30, true);
// $browser->clickAtXPath('//*[contains(text(), "Upload your avatar")]');
// Upload the downloaded image
$browser->attach('input[type="file"]', $imagePath);
// Wait for and click save
$this->waitForAndClickText($browser, "Save");
// Go back to dailyfree
$browser->visit('https://hellcase.com/dailyfree');
sleep(10);
try {
// wait and click "Check the condition"
$this->waitForAndClickText($browser, "Check the condition");
$browser->waitForText("Your Steam profile avatar does not match any of the ones specified in the", 30, true);
} catch (\Exception $e) {
// If the text is not found, the condition is filled
return;
}
}
private function changeSteamProfileToPublic(Browser $browser) {
$this->goToSteamProfileSettings($browser);
// Wait for and click "Privacy Settings"
$this->waitForAndClickText($browser, "Privacy Settings");
$dropdownButton = $browser->driver->findElement(WebDriverBy::xpath('//div[text() = "My profile"]/div'));
$dropdownButton->click();
sleep(2);
// Div that contains the visible class ath the body root an element with public text
$publicOption = $browser->driver->findElement(WebDriverBy::xpath('/body/div[contains(@class, "visible")]/div[contains(text(), "Public")]'));
$publicOption->click();
// Go back to dailyfree
$browser->visit('https://hellcase.com/dailyfree');
}
/**
* From the dailyfree page
* @param \Laravel\Dusk\Browser $browser
* @return void
*/
private function goToSteamProfileSettings(Browser $browser) {
// Get the link that has text "Steam profile"
$steamProfileLink = $browser->driver->findElement(WebDriverBy::xpath('//a[contains(text(), "Steam profile")]'));
$browser->visit($steamProfileLink->getAttribute("href"));
$browser->waitForText("Level");
// Click "Edit Profile
$browser->clickAtXPath('//*[contains(text(), "Edit Profile")]');
}
private function removePopups(Browser $browser)
{
// $browser->script('document.querySelector("div.app-modal")[0].remove();');
// $browser->driver->executeScript('document.querySelector("div.app-modal")[0].remove();');
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Browser\Jobs\Hellcase;
use Laravel\Dusk\Browser;
use function rtrim;
class HellcaseLoginQrCode {
public const IMG_FILE_NAME = "SteamQRCode.png";
/**
* QR code validity in seconds
* @var int
*/
public const QR_CODE_VALIDITY = 20;
public static function getImgFileAbsolutePath(): string {
return rtrim(Browser::$storeScreenshotsAt, '/') . "/Hellcase/" . static::IMG_FILE_NAME;
}
public static function getImgFileProjectPath(): string {
return app_path("Browser/screenshots/Hellcase/" . static::IMG_FILE_NAME);
}
public static function getImgFileExternalPath(): string {
return "screenshots/Hellcase/" . static::IMG_FILE_NAME;
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Browser\Jobs\Parameters;
use App\Browser\BrowserJob;
use App\Models\JobArtifact;
use App\Models\JobRun;
use App\Notification\Notifications\SimpleNotification;
use App\Notification\Providers\AllNotification;
use Laravel\Dusk\Browser;
class ParametersJob extends BrowserJob
{
public function __construct()
{
parent::__construct(1);
}
/**
* @inheritDoc
*/
public function run(Browser $browser): ?JobRun
{
return null;
}
/**
* @inheritDoc
*/
public function runTest(Browser $browser): ?JobRun
{
try {
AllNotification::send(new SimpleNotification($this->jobId, "Test", "Test des notifications"));
AllNotification::send(new SimpleNotification($this->jobId, "Test", "Test des notifications d'erreur", true));
return $this->makeSimpleJobRun(true, "Envoi de notification réussi", "Datboi a réussi à envoyer des notifications");
} catch (\Throwable $e) {
return $this->makeSimpleJobRun(false, "Envoi de notification échoué", "Datboi n'a pas réussi à envoyer des notifications :\n" . $e->getMessage());
}
}
}

2
app/Browser/downloads/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers;
use App\Exception\JobException;
use App\Models\Job;
use App\Services\BrowserJobsInstances;
use Cache;
use Illuminate\Http\Request;
use Inertia\Inertia;
use App\Exception\JobTestException;
class JobController extends Controller
{
public function __construct(
protected BrowserJobsInstances $jobInstances,
) {
}
public function show($jobId, Request $request)
{
return Inertia::render('Job', [
'job' => Job::where('id', $jobId)->with('jobInfos')->first(),
'error' => $request->input('error'),
]);
}
public function update($jobId, Request $request)
{
$job = Job::where('id', $jobId)->first();
$job->is_active = false; // Disable the job
$errors = [];
foreach ($job->jobInfos()->get() as $jobInfo) {
$value = $request->input($jobInfo->id);
if (!isset($value) && $jobInfo->is_required) {
$errors[] = 'Le champ ' . $jobInfo->name . ' est requis.';
continue;
}
Cache::forget($jobInfo->key);
$jobInfo->value = $value;
$jobInfo->save();
}
$job->save();
if (count($errors) > 0) {
return redirect()->route('jobs.show', ['job' => $job, 'error' => implode('<br />', $errors)]);
}
$job->is_active = $request->input('is_active');
$job->save();
return redirect()->route('jobs.show', ['job' => $job]);
}
public function test($jobId, Request $request)
{
$log = $this->jobInstances->getJobInstance($jobId)->executeTest();
if (!empty($log)) {
return response()->json(['artifact' => $log->load('artifacts')]);
}
return response()->json([]);
}
}

View File

@ -7,6 +7,22 @@ use Illuminate\Database\Eloquent\Model;
class Job extends Model
{
/** @use HasFactory<\Database\Factories\JobFactory> */
use HasFactory;
protected $fillable = [
"is_active",
];
protected $casts = [
"is_active" => "boolean",
];
public function jobInfos()
{
return $this->hasMany(JobInfo::class)->with("jobInfoType")->orderBy("created_at");
}
public function jobRuns()
{
return $this->hasMany(JobRun::class)->orderBy("created_at");
}
}

View File

@ -4,9 +4,17 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class JobArtifact extends Model
{
/** @use HasFactory<\Database\Factories\JobArtifactFactory> */
use HasFactory;
protected $fillable = [
"name",
"content",
];
public function jobRun(): BelongsTo
{
return $this->belongsTo(JobRun::class);
}
}

View File

@ -7,6 +7,19 @@ use Illuminate\Database\Eloquent\Model;
class JobInfo extends Model
{
/** @use HasFactory<\Database\Factories\JobInfoFactory> */
use HasFactory;
protected $fillable = [
"value",
];
public function job()
{
return $this->belongsTo(Job::class);
}
public function jobInfoType()
{
return $this->belongsTo(JobInfoType::class)->select("id", "name");
}
}

View File

@ -7,6 +7,12 @@ use Illuminate\Database\Eloquent\Model;
class JobInfoType extends Model
{
/** @use HasFactory<\Database\Factories\JobInfoTypeFactory> */
use HasFactory;
protected $fillable = [
"name",
];
public function jobInfos()
{
return $this->hasMany(JobInfo::class);
}
}

View File

@ -4,9 +4,34 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class JobRun extends Model
{
/** @use HasFactory<\Database\Factories\JobRunFactory> */
use HasFactory;
protected $fillable = [
"job_id",
"success",
];
protected $casts = [
"success" => "boolean",
];
protected $with = ['artifacts'];
public function job(): BelongsTo
{
return $this->belongsTo(Job::class);
}
public function artifacts(): HasMany
{
return $this->hasMany(JobArtifact::class);
}
public function addArtifact(JobArtifact $artifact): void
{
$this->artifacts()->save($artifact);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Notification;
use App\Models\Job;
use App\Notification\Stringifiable\StringifiableSimpleText;
abstract class Notification {
protected Job $job;
private NotificationBody $body;
public bool $isError;
public function __construct(int $jobId, NotificationBody $body, bool $isError = false) {
$this->job = Job::find($jobId);
$this->body = $body;
$this->isError = $isError;
}
public function getTitle(): Stringifiable {
return new StringifiableSimpleText($this->job->name);
}
public function getBody(): Stringifiable {
return $this->body;
}
abstract public function getLinkURL(): ?string;
public function getImageURL(): ?string {
$imageProjectPath = $this->getImageProjectPath();
if ($imageProjectPath === null) {
return null;
}
return url($imageProjectPath);
}
abstract public function getImageProjectPath(): ?string;
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Notification;
use App\Notification\Stringifiable;
abstract class NotificationBody extends Stringifiable {
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Notification\NotificationBody\Hellcase;
use App\Browser\Jobs\Hellcase\HellcaseLoginQrCode;
use App\Notification\NotificationBody;
class HellcaseNotificationDailyFreeBody extends NotificationBody {
private string $content = "Vous avez remporté un cadeau gratuit sur Hellcase !";
/**
* @inheritDoc
*/
public function toMarkdownString(): string {
return $this->content;
}
/**
* @inheritDoc
*/
public function toString(): string {
return $this->content;
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Notification\NotificationBody\Hellcase;
use App\Browser\Jobs\Hellcase\HellcaseLoginQrCode;
use App\Notification\NotificationBody;
class HellcaseNotificationLoginBody extends NotificationBody {
private string $content = "Veuillez utiliser steam guard pour vous connecter à Hellcase.\nLe QR code se rafrachira toutes les ". HellcaseLoginQrCode::QR_CODE_VALIDITY ." secondes.";
/**
* @inheritDoc
*/
public function toMarkdownString(): string {
return $this->content;
}
/**
* @inheritDoc
*/
public function toString(): string {
return $this->content;
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Notification\NotificationBody;
use App\Notification\NotificationBody;
use App\Notification\Stringifiable;
class ListNotificationBody extends NotificationBody {
private array $content;
public function __construct(array $content) {
$this->content = $content;
}
/**
* @inheritDoc
*/
public function toMarkdownString(): string {
$string = "";
foreach ($this->content as $item) {
$string .= "- ". $this->getTextFromContent($item) . "\n";
}
return $string;
}
/**
* @inheritDoc
*/
public function toString(): string {
$string = "";
foreach ($this->content as $item) {
$string .= $this->getTextFromContent($item) . "\n";
}
return $string;
}
public function getTextFromContent(string|Stringifiable $content): string {
if ($content instanceof Stringifiable) {
return $content->toString();
}
return $content;
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Notification\NotificationBody;
use App\Notification\NotificationBody;
use App\Notification\Stringifiable;
class SimpleNotificationBody extends NotificationBody {
private string $body;
public function __construct(string $body) {
$this->body = $body;
}
/**
* @inheritDoc
*/
public function toMarkdownString(): string {
return $this->body;
}
public function toHTMLString(): string {
return $this->body;
}
/**
* @inheritDoc
*/
public function toString(): string {
return $this->body;
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace App\Notification;
abstract class NotificationProvider {
abstract public static function send(Notification $notification): void;
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Notification\Notifications\Hellcase;
use App\Browser\Jobs\Hellcase\HellcaseDailyFreeScreenshot;
use App\Browser\Jobs\Hellcase\HellcaseLoginQrCode;
use App\Notification\Notification;
use App\Notification\Notifications\NotificationLogin;
use Laravel\Dusk\Browser;
class HellcaseNotificationDailyFree extends NotificationLogin {
public function __construct(int $jobId, \App\Notification\NotificationBody $body) {
parent::__construct($jobId, $body);
}
/**
* @inheritDoc
*/
public function getImageProjectPath(): string|null {
return HellcaseDailyFreeScreenshot::getImgFileProjectPath();
}
/**
* @inheritDoc
*/
public function getLinkURL(): string|null {
return route('jobs.show', ['job' => $this->job->id]);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Notification\Notifications\Hellcase;
use App\Browser\Jobs\Hellcase\HellcaseLoginQrCode;
use App\Notification\Notification;
use App\Notification\Notifications\NotificationLogin;
use Laravel\Dusk\Browser;
class HellcaseNotificationLogin extends NotificationLogin {
public function __construct(int $jobId, \App\Notification\NotificationBody $body) {
parent::__construct($jobId, $body);
}
/**
* @inheritDoc
*/
public function getImageProjectPath(): string|null {
return HellcaseLoginQrCode::getImgFileProjectPath();
}
/**
* @inheritDoc
*/
public function getLinkURL(): string|null {
return route('jobs.show', ['job' => $this->job->id]);
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Notification\Notifications;
use App\Notification\Notification;
abstract class NotificationLogin extends Notification {
public function __construct(int $jobId, \App\Notification\NotificationBody $body) {
parent::__construct($jobId, $body, true);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Notification\Notifications;
use App\Notification\Notification;
use App\Notification\NotificationBody\SimpleNotificationBody;
use App\Notification\Stringifiable;
use App\Notification\Stringifiable\StringifiableSimpleText;
class SimpleNotification extends Notification {
private StringifiableSimpleText $title;
public function __construct(int $jobId, string $title, string $body, bool $isError = false) {
$this->title = new StringifiableSimpleText($title);
parent::__construct($jobId, new SimpleNotificationBody($body), $isError);
}
public function getTitle(): Stringifiable {
return $this->title;
}
/**
* @inheritDoc
*/
public function getImageProjectPath(): string|null {
return null;
}
/**
* @inheritDoc
*/
public function getLinkURL(): string|null {
return route('home');
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Notification\Providers;
use App\Notification\NotificationProvider;
use App\Notification\INotificationProvider;
use App\Models\JobInfo;
use Illuminate\Support\Facades\Cache;
class AllNotification extends NotificationProvider {
private const NOTIFICATIONS_PROVIDERS = [
DiscordWebHookNotification::class,
];
/**
* @inheritDoc
*/
public static function send(\App\Notification\Notification $notification): void {
foreach (self::NOTIFICATIONS_PROVIDERS as $provider) {
$provider::send($notification);
}
}
}

View File

@ -0,0 +1,110 @@
<?php
namespace App\Notification\Providers;
use App\Notification\NotificationProvider;
use App\Notification\INotificationProvider;
use App\Models\JobInfo;
use Illuminate\Support\Facades\Cache;
class DiscordWebHookNotification extends NotificationProvider {
private const EMBED_COLOR = ["521254", "16058119"];
/**
* @inheritDoc
*/
public static function send(\App\Notification\Notification $notification): void {
/*
Test Json for a complete embed :
{
"content": "",
"tts": false,
"embeds": [
{
"id": 652627557,
"title": "Title",
"description": "This is the markdown body\n",
"color": 521254,
"fields": [
{
"id": 984079152,
"name": "Field 1",
"value": "test"
}
],
"url": "https://localhost:8000",
"image": {
"url": "https://www.thoughtco.com/thmb/jzJO77P0K9zIbqxQOVOaHWFCfj4=/1732x1732/filters:fill(auto,1)/GettyImages-186451154-58c3965a3df78c353cf8cc7b.jpg"
}
}
],
"components": [],
"actions": {},
"username": "Datboi",
"avatar_url": "https://www.fairytailrp.com/t40344-here-come-dat-boi"
}
*/
$webHookUrl = static::getDiscordWebHookUrl($notification->isError);
$body = [
"content"=> "",
"tts"=> false,
"embeds" => [
[
"id" => 652627557,
"title" => $notification->getTitle()->toString(),
"description" => $notification->getBody()->toMarkdownString(),
"color" => self::EMBED_COLOR[(int)$notification->isError],
"url" => $notification->getLinkURL(),
],
],
"username" => "Datboi",
"avatar_url" => "https://media1.giphy.com/media/yDTWAecZcB2Jq/200w.gif?cid=6c09b952f68kz3wnkqsmyha8e7xrpe8n2kx0nkf2b8cir6am&rid=200w.gif&ct=g",
];
if ($notification->getImageURL() !== null) {
$body["embeds"][0]["image"] = [
"url" => "attachment://image.png"
];
}
$payloadJson = json_encode($body);
$formData = [
'payload_json' => $payloadJson,
];
if ($notification->getImageURL() !== null) {
$formData['file'] = curl_file_create($notification->getImageProjectPath(), 'image/png', 'image.png');
}
$ch = curl_init($webHookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-type: multipart/form-data'));
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $formData);
// curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$response = curl_exec($ch);
curl_close($ch);
}
private static function getDiscordWebHookUrl(bool $isError): string {
$generalWebHookUrlKey = 'discord_webhook_url';
$generalWebHookUrl = Cache::rememberForever($generalWebHookUrlKey, function () use ($generalWebHookUrlKey) {
return JobInfo::where('key', $generalWebHookUrlKey)->first()->value;
});
if ($generalWebHookUrl === null) {
throw new \Exception("Le webhook discord n'a pas été configuré");
}
if ($isError) {
$errorWebHookUrlKey = 'discord_error_webhook_url';
$errorWebHookUrl = Cache::rememberForever($errorWebHookUrlKey, function () use ($errorWebHookUrlKey) {
return JobInfo::where('key', $errorWebHookUrlKey)->first()->value;
});
return $errorWebHookUrl ?? $generalWebHookUrl;
}
else {
return $generalWebHookUrl;
}
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Notification;
use Parsedown;
abstract class Stringifiable{
private Parsedown $parsedown;
public function __construct() {
$this->parsedown = new Parsedown();
}
abstract public function toString(): string;
public function toHTMLString(): string {
return $this->parsedown->text($this->toMarkdownString());
}
abstract public function toMarkdownString(): string;
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Notification\Stringifiable;
use App\Notification\Stringifiable;
class StringifiableSimpleText extends Stringifiable {
private string $text;
public function __construct(string $text) {
$this->text = $text;
}
/**
* @inheritDoc
*/
public function toHTMLString(): string {
return $this->text;
}
/**
* @inheritDoc
*/
public function toMarkdownString(): string {
return $this->text;
}
/**
* @inheritDoc
*/
public function toString(): string {
return $this->text;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Providers;
use App\Browser\BrowserJob;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;

View File

@ -0,0 +1,37 @@
<?php
namespace App\Providers;
use App\Services\BrowserJobsInstances;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
class BrowserJobsServiceProvider extends ServiceProvider
{
private BrowserJobsInstances $service;
public function __construct(Application $app)
{
parent::__construct($app);
$this->service = new BrowserJobsInstances();
}
/**
* Register services.
*/
public function register(): void
{
$this->app->instance(BrowserJobsInstances::class, $this->service);
}
/**
* Bootstrap services.
*/
public function boot(): void
{
$this->app->terminating(function (BrowserJobsInstances $instances) {
$instances->terminateAll();
});
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Services;
use App\Browser\BrowserJob;
use Cache;
use Exception;
class BrowserJobsInstances {
/**
* A dictionnary of all job instances by their jobId
* @var array Dictionnary of BrowserJob by their jobId
*/
private $jobInstancesByJobId = [];
/**
* Index all jobs in the app/Browser/Jobs directory
* and store them in the cache
* @return void
*/
private function indexJobsClassesById(): void
{
// Read all directories in app/Browser/jobs,
// foreach directory, get the file named {directoryName}Job.php if it exists
$folders = scandir(app_path('Browser/Jobs'));
$files = [];
foreach ($folders as $folder) {
if ($folder == '.' || $folder == '..') {
continue;
}
if (is_dir(app_path('Browser/Jobs/' . $folder))) {
$potentialFileName = $folder . '/' . $folder . 'Job.php';
if (file_exists(app_path('Browser/Jobs/' . $potentialFileName))) {
$files[] = $potentialFileName;
}
}
}
// Make a dictionnary of the id of the job as the key and the job class instance as value
foreach ($files as $file) {
$className = str_replace('.php', '', $file);
$className = str_replace('/', '\\', $className);
$fullClassName = 'App\Browser\Jobs\\' . $className;
$jobInstance = new $fullClassName();
$jobId = $jobInstance->jobId;
Cache::put('jobClass' . $jobId, $fullClassName); // Met le nom de la classe en cache
$this->jobInstancesByJobId[$jobId] = $jobInstance;
}
}
/**
* Get an instance of a job by it's jobId
* @param mixed $jobId The ID of the job in the database
* @return BrowserJob
*/
public function getJobInstance($jobId): BrowserJob
{
try {
return $this->jobInstancesByJobId[$jobId];
} catch (Exception $e) {
return $this->getNewJobInstance($jobId);
}
}
/**
* Create a new instance of a job class
* @param int $jobId
* @return object
*/
private function getNewJobInstance(int $jobId): BrowserJob {
$jobClass = Cache::get('jobClass' . $jobId);
if ($jobClass == null) { // If we don't have the class in cache, we put all of the job in cache
$this->indexJobsClassesById();
return $this->jobInstancesByJobId[$jobId]; // indexJobsClassesById() already created an instance of the job
}
// If we have the class in cache, we create a new instance of it
$instance = new $jobClass();
$this->jobInstancesByJobId[$jobId] = $instance;
return $instance;
}
/**
* Terminate all jobs
* @return void
*/
public function terminateAll() {
foreach ($this->jobInstancesByJobId as $jobInstance) {
$jobInstance->terminate();
}
}
}

View File

@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)

View File

@ -2,4 +2,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\BrowserJobsServiceProvider::class,
];

54
compose.prod.yaml Normal file
View File

@ -0,0 +1,54 @@
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/
# Here the instructions define your application as a service called "app".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
app:
image: git.matthiasg.dev/ninluc/datbrowser:latest
restart: unless-stopped
ports:
- 80:80
depends_on:
db:
condition: service_healthy
undetected-chromedriver:
condition: service_healthy
db:
image: mysql:5.7
restart: unless-stopped
healthcheck:
test: ["CMD", 'mysqladmin', 'ping', '-h', 'db', '-u', '${DB_USERNAME}', '-p${DB_PASSWORD}' ]
interval: 2s
timeout: 20s
retries: 10
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- dbdata:/var/lib/mysql
undetected-chromedriver:
image: git.matthiasg.dev/ninluc/selenium/standalone-uc:latest
restart: unless-stopped
volumes:
- /tmp:/tmp
shm_size: 2gb
ports:
- "4444:4444"
- "7900:7900"
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
volumes:
dbdata:

53
compose.yaml Normal file
View File

@ -0,0 +1,53 @@
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/
# Here the instructions define your application as a service called "app".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
app:
build:
context: .
target: final
restart: unless-stopped
ports:
- 80:80
depends_on:
db:
condition: service_healthy
undetected-chromedriver:
condition: service_healthy
db:
image: mysql:5.7
restart: unless-stopped
healthcheck:
test: ["CMD", 'mysqladmin', 'ping', '-h', 'db', '-u', '${DB_USERNAME}', '-p${DB_PASSWORD}' ]
interval: 2s
timeout: 20s
retries: 10
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- dbdata:/var/lib/mysql
undetected-chromedriver:
build:
context: .
dockerfile: undetectedChromedriver/seleniumChromedriverDockerfile
restart: unless-stopped
volumes:
- /tmp:/tmp
shm_size: 2gb
ports:
- "4444:4444"
- "7900:7900"
volumes:
dbdata:

View File

@ -10,7 +10,9 @@
"license": "MIT",
"require": {
"php": "^8.2",
"erusev/parsedown": "^1.7",
"inertiajs/inertia-laravel": "^2.0",
"laravel/dusk": "^8.2",
"laravel/framework": "^11.31",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.9",

216
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2f8fad98d414b6e76b3cad63a981e25e",
"content-hash": "fe9d04d1c63e92ba62edb93168567747",
"packages": [
{
"name": "brick/math",
@ -510,6 +510,56 @@
],
"time": "2024-12-27T00:36:43+00:00"
},
{
"name": "erusev/parsedown",
"version": "1.7.4",
"source": {
"type": "git",
"url": "https://github.com/erusev/parsedown.git",
"reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/erusev/parsedown/zipball/cb17b6477dfff935958ba01325f2e8a2bfa6dab3",
"reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35"
},
"type": "library",
"autoload": {
"psr-0": {
"Parsedown": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Emanuil Rusev",
"email": "hello@erusev.com",
"homepage": "http://erusev.com"
}
],
"description": "Parser for Markdown.",
"homepage": "http://parsedown.org",
"keywords": [
"markdown",
"parser"
],
"support": {
"issues": "https://github.com/erusev/parsedown/issues",
"source": "https://github.com/erusev/parsedown/tree/1.7.x"
},
"time": "2019-12-30T22:54:17+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.3.0",
@ -970,16 +1020,16 @@
},
{
"name": "guzzlehttp/uri-template",
"version": "v1.0.3",
"version": "v1.0.4",
"source": {
"type": "git",
"url": "https://github.com/guzzle/uri-template.git",
"reference": "ecea8feef63bd4fef1f037ecb288386999ecc11c"
"reference": "30e286560c137526eccd4ce21b2de477ab0676d2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/uri-template/zipball/ecea8feef63bd4fef1f037ecb288386999ecc11c",
"reference": "ecea8feef63bd4fef1f037ecb288386999ecc11c",
"url": "https://api.github.com/repos/guzzle/uri-template/zipball/30e286560c137526eccd4ce21b2de477ab0676d2",
"reference": "30e286560c137526eccd4ce21b2de477ab0676d2",
"shasum": ""
},
"require": {
@ -1036,7 +1086,7 @@
],
"support": {
"issues": "https://github.com/guzzle/uri-template/issues",
"source": "https://github.com/guzzle/uri-template/tree/v1.0.3"
"source": "https://github.com/guzzle/uri-template/tree/v1.0.4"
},
"funding": [
{
@ -1052,7 +1102,7 @@
"type": "tidelift"
}
],
"time": "2023-12-03T19:50:20+00:00"
"time": "2025-02-03T10:55:03+00:00"
},
{
"name": "inertiajs/inertia-laravel",
@ -1129,17 +1179,89 @@
"time": "2024-12-13T02:48:29+00:00"
},
{
"name": "laravel/framework",
"version": "v11.41.0",
"name": "laravel/dusk",
"version": "v8.2.14",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "42d6ae000c868c2abfa946da46702f2358493482"
"url": "https://github.com/laravel/dusk.git",
"reference": "28c9fce3900625522afc2468a9117cdf44f919c1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/42d6ae000c868c2abfa946da46702f2358493482",
"reference": "42d6ae000c868c2abfa946da46702f2358493482",
"url": "https://api.github.com/repos/laravel/dusk/zipball/28c9fce3900625522afc2468a9117cdf44f919c1",
"reference": "28c9fce3900625522afc2468a9117cdf44f919c1",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-zip": "*",
"guzzlehttp/guzzle": "^7.5",
"illuminate/console": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"php": "^8.1",
"php-webdriver/webdriver": "^1.15.2",
"symfony/console": "^6.2|^7.0",
"symfony/finder": "^6.2|^7.0",
"symfony/process": "^6.2|^7.0",
"vlucas/phpdotenv": "^5.2"
},
"require-dev": {
"mockery/mockery": "^1.6",
"orchestra/testbench": "^8.19|^9.0|^10.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.1|^11.0",
"psy/psysh": "^0.11.12|^0.12"
},
"suggest": {
"ext-pcntl": "Used to gracefully terminate Dusk when tests are running."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Dusk\\DuskServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Dusk\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Dusk provides simple end-to-end testing and browser automation.",
"keywords": [
"laravel",
"testing",
"webdriver"
],
"support": {
"issues": "https://github.com/laravel/dusk/issues",
"source": "https://github.com/laravel/dusk/tree/v8.2.14"
},
"time": "2025-01-26T19:36:00+00:00"
},
{
"name": "laravel/framework",
"version": "v11.41.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "3ef433d5865f30a19b6b1be247586068399b59cc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/3ef433d5865f30a19b6b1be247586068399b59cc",
"reference": "3ef433d5865f30a19b6b1be247586068399b59cc",
"shasum": ""
},
"require": {
@ -1341,7 +1463,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2025-01-28T15:22:55+00:00"
"time": "2025-01-30T13:25:22+00:00"
},
{
"name": "laravel/prompts",
@ -2646,6 +2768,72 @@
],
"time": "2024-11-21T10:39:51+00:00"
},
{
"name": "php-webdriver/webdriver",
"version": "1.15.2",
"source": {
"type": "git",
"url": "https://github.com/php-webdriver/php-webdriver.git",
"reference": "998e499b786805568deaf8cbf06f4044f05d91bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/998e499b786805568deaf8cbf06f4044f05d91bf",
"reference": "998e499b786805568deaf8cbf06f4044f05d91bf",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-zip": "*",
"php": "^7.3 || ^8.0",
"symfony/polyfill-mbstring": "^1.12",
"symfony/process": "^5.0 || ^6.0 || ^7.0"
},
"replace": {
"facebook/webdriver": "*"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.20.0",
"ondram/ci-detector": "^4.0",
"php-coveralls/php-coveralls": "^2.4",
"php-mock/php-mock-phpunit": "^2.0",
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpunit/phpunit": "^9.3",
"squizlabs/php_codesniffer": "^3.5",
"symfony/var-dumper": "^5.0 || ^6.0 || ^7.0"
},
"suggest": {
"ext-SimpleXML": "For Firefox profile creation"
},
"type": "library",
"autoload": {
"files": [
"lib/Exception/TimeoutException.php"
],
"psr-4": {
"Facebook\\WebDriver\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.",
"homepage": "https://github.com/php-webdriver/php-webdriver",
"keywords": [
"Chromedriver",
"geckodriver",
"php",
"selenium",
"webdriver"
],
"support": {
"issues": "https://github.com/php-webdriver/php-webdriver/issues",
"source": "https://github.com/php-webdriver/php-webdriver/tree/1.15.2"
},
"time": "2024-11-21T15:12:59+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.3",

8
config/dusk.php Normal file
View File

@ -0,0 +1,8 @@
<?php
return [
"driver" => [
"url" => $_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? null
]
];

83
config/sanctum.php Normal file
View File

@ -0,0 +1,83 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@ -17,7 +17,7 @@ return new class extends Migration
$table->string('name');
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->boolean('is_active')->default(false);
$table->timestamps();
});
@ -38,21 +38,24 @@ return new class extends Migration
$jobs = [
[
'id' => 1,
'name' => 'Hellcase',
'description' => 'Prends le daily free et rentre dans les concours. Tourne toutes les 24h.',
'is_active' => false,
'name' => 'Paramètres généraux',
'description' => "Les paramètres généraux de l'application.",
'is_active' => true,
],
[
'id' => 2,
'name' => 'Jeu gratuit Epic Games',
'description' => 'Prends le jeu gratuit Epic games. Tourne tous les mois et tous les jours pendant la période de Noël.',
'is_active' => false,
'name' => 'Hellcase',
'description' => 'Prends le daily free et rentre dans les concours. Tourne toutes les 24h.',
],
[
'id' => 3,
'name' => 'Jeu gratuit Epic Games',
'description' => 'Prends le jeu gratuit Epic games. Tourne tous les mois et tous les jours pendant la période de Noël.',
],
[
'id' => 4,
'name' => 'Envoyer un post instagram',
'description' => "Envoye un post instagram avec l'image et le texte fourni. Tourne tous les jours.",
'is_active' => false,
],
];

View File

@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('job_info_types', function (Blueprint $table) {
$table->id();
$table->string('name')->unique()->index(); // html input type
$table->timestamps();
});
$this->seed();
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('job_info_types');
}
public function seed(): void
{
$jobInfosTypes = [
[
"id" => 1,
"name" => "text",
],
[
"id" => 2,
"name" => "email",
],
[
"id" => 3,
"name" => "password",
],
[
"id" => 4,
"name" => "url",
],
];
foreach ($jobInfosTypes as $jobInfoType) {
\App\Models\JobInfoType::forceCreate($jobInfoType);
}
}
};

View File

@ -0,0 +1,107 @@
<?php
use App\Models\JobInfoType;
use App\Models\Job;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('job_infos', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->string('name');
$table->text('description')->nullable();
$table->string('placeholder')->nullable();
$table->text('value')->nullable();
$table->boolean('is_required')->default(true);
$table->foreignIdFor(JobInfoType::class, "job_info_type_id")->constrained()->onDelete('cascade');
$table->foreignIdFor(Job::class,'job_id')->constrained()->onDelete('cascade');
$table->timestamps();
});
$this->seed();
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('job_infos');
}
public function seed(): void
{
$jobInfos = [
/* GENERAL PARAMETERS */
[
"key" => "discord_webhook_url",
"name" => "Messages - Webhook Discord",
"description" => "Le lien discord webhook utilisé pour envoyer les messages des résultats des jobs.",
"placeholder" => "https://discord.com/api/webhooks/...",
"job_info_type_id" => 4,
"job_id" => 1,
],
[
"key" => "discord_error_webhook_url",
"name" => "Alertes - Webhook Discord",
"description" => "Le lien discord webhook utilisé pour envoyer les messages d'erreur et d'alerte. Laisser vide pour utiliser le même que les messages.",
"placeholder" => "https://discord.com/api/webhooks/...",
"is_required" => false,
"job_info_type_id" => 4,
"job_id" => 1,
],
/* EPIC GAMES */
[
"key" => "epicgames_account_email",
"name" => "E-mail",
"description" => "L'adresse e-mail utilisée pour votre compte Epic Games.",
"job_info_type_id" => 2,
"job_id" => 3,
],
[
"key" => "epicgames_account_password",
"name" => "Mot de passe",
"description" => "Le mot de passe utilisé pour votre compte Epic Games.",
"job_info_type_id" => 3,
"job_id" => 3,
],
/* INSTAGRAM */
[
"key" => "instagram_account_email",
"name" => "E-mail",
"description" => "L'adresse e-mail utilisée pour votre compte Instagram.",
"job_info_type_id" => 2,
"job_id" => 4,
],
[
"key" => "instagram_account_password",
"name" => "Mot de passe",
"description" => "Le mot de passe utilisé pour votre compte Instagram.",
"job_info_type_id" => 3,
"job_id" => 4,
],
];
foreach ($jobInfos as $jobInfo) {
\App\Models\JobInfo::forceCreate($jobInfo);
}
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('job_runs', function (Blueprint $table) {
$table->id();
$table->boolean('success')->default(true);
$table->foreignIdFor(\App\Models\Job::class)->constrained()->cascadeOnDelete();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('job_runs');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('job_artifacts', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('content')->nullable();
$table->foreignIdFor(\App\Models\JobRun::class)->constrained()->cascadeOnDelete();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('job_artifacts');
}
};

7
dockerEntryPoint.sh Normal file
View File

@ -0,0 +1,7 @@
#!/bin/sh
# Migrate the database
php ./artisan migrate --force
# Start all of the server sumulataneously
php ./artisan serve --no-interaction -vvv --port=80 --host=0.0.0.0

92
package-lock.json generated
View File

@ -5,6 +5,7 @@
"packages": {
"": {
"dependencies": {
"@vueuse/core": "^12.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.474.0",
@ -1154,48 +1155,34 @@
"integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ=="
},
"node_modules/@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"version": "12.5.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.5.0.tgz",
"integrity": "sha512-GVyH1iYqNANwcahAx8JBm6awaNgvR/SwZ1fjr10b8l1HIgDp82ngNbfzJUgOgWEoxjL+URAggnlilAEXwCOZtg==",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
"@vueuse/metadata": "12.5.0",
"@vueuse/shared": "12.5.0",
"vue": "^3.5.13"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
"node_modules/@vueuse/core/node_modules/@vueuse/shared": {
"version": "12.5.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.5.0.tgz",
"integrity": "sha512-vMpcL1lStUU6O+kdj6YdHDixh0odjPAUM15uJ9f7MY781jcYkIwFA4iv2EfoIPO6vBmvutI1HxxAwmf0cx5ISQ==",
"dependencies": {
"vue": "^3.5.13"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
"version": "12.5.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.5.0.tgz",
"integrity": "sha512-Ui7Lo2a7AxrMAXRF+fAp9QsXuwTeeZ8fIB9wsLHqzq9MQk+2gMYE2IGJW48VMJ8ecvCB3z3GsGLKLbSasQ5Qlg==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
@ -2768,6 +2755,53 @@
"vue": ">= 3.2.0"
}
},
"node_modules/radix-vue/node_modules/@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/radix-vue/node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/radix-vue/node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/radix-vue/node_modules/nanoid": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz",

View File

@ -22,6 +22,7 @@
"vue-tsc": "^2.0.24"
},
"dependencies": {
"@vueuse/core": "^12.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.474.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -0,0 +1,4 @@
#!/bin/bash
sudo docker build -f undetectedChromedriver/seleniumChromedriverDockerfile -t git.matthiasg.dev/ninluc/selenium/standalone-uc:latest .
sudo docker push git.matthiasg.dev/ninluc/selenium/standalone-uc:latest

View File

@ -3,8 +3,8 @@ import { appName } from '@/app.ts'
</script>
<template>
<router-link class="flex justify-center items-center gap-3" to="/">
<Link class="flex justify-center items-center gap-3" href="/">
<img src="@/Assets/logo.png" alt="logo DatBrowser" class="h-20" />
<h1 class="text-xl font-display select-none">{{ appName }}</h1>
</router-link>
</Link>
</template>

View File

@ -1,4 +1,8 @@
<script setup lang="ts">
import Description from '@/Components/ui/text/Description.vue';
</script>
<template>
<h2 class="text-xl"><slot name="title" /></h2>
<p class="text-dark-green"><slot name="description" /></p>
<Description><slot name="description" /></Description>
</template>

View File

@ -0,0 +1,139 @@
<script setup lang="ts">
import { Job, JobArtifact, JobInfo, JobInfoType, JobRunArtifact } from "@/types/Jobs/job";
import { router } from "@inertiajs/vue3";
import { onMounted, reactive, ref, watch } from "vue";
import Button from "../../ui/button/Button.vue";
import JobFormField from "./JobFormField.vue";
import LoadingSpinner from "@/Components/ui/feedback/spinner/LoadingSpinner.vue";
import { httpApi } from "@/lib/utils";
const props = withDefaults(defineProps<{
job: Job;
error?: string;
}>(), {
error: "",
});
const isSaving = ref(false);
const isTesting = ref(false);
const errorMessage = ref(props.error);
const jobSuccess = ref(true);
function submit() {
isSaving.value = true;
if (!testForm()) {
isTesting.value = false;
return;
}
router.patch("/jobs/" + props.job.id, {
is_active: isActiveJobInfo.value.value,
...props.job.job_infos.reduce((acc, jobInfo) => {
acc[jobInfo.id] = jobInfo.value;
return acc;
}, {} as Record<number, string | boolean>),
});
setTimeout(() => {
isSaving.value = false;
}, 200);
}
const isActiveJobInfo = ref<JobInfo>({
id: 0,
name: "Activer",
description: "Activer le job",
value: props.job.is_active,
is_required: false,
job_info_type: { name: "checkbox" } as JobInfoType,
} as JobInfo);
async function testJob() {
isTesting.value = true;
submit();
console.log("Testing job", props.job.id);
let response;
try {
response = await httpApi<{ artifact?: JobRunArtifact }>(
`/jobs/${props.job.id}/test`
);
jobSuccess.value = response.artifact?.success ?? false;
} catch (e) {
console.error("Testing the job failed : ", e);
jobSuccess.value = false;
} finally {
isTesting.value = false;
}
if (response?.artifact) {
alert(response.artifact.artifacts[0].name + " : " + response.artifact.artifacts[0].content);
}
}
function testForm(): boolean {
console.log("Testing the form validity");
const jobForm = document.querySelector('form[name="jobForm"]') as HTMLFormElement;
if (jobForm.checkValidity() == false) {
console.log("Form is not valid");
if (jobForm.reportValidity) {
jobForm.reportValidity()
}
else {
errorMessage.value = "Le formulaire n'est pas valide";
}
isTesting.value = false;
return false;
}
return true;
}
</script>
<template>
<form @submit.prevent="submit" class="flex p-3 flex-col gap-4" name="jobForm">
<JobFormField v-if="job.id != 1" :jobInfo="isActiveJobInfo" />
<JobFormField
:jobInfo="jobInfo"
v-for="jobInfo of job.job_infos"
v-key="jobInfo.id"
/>
<Transition>
<p v-if="errorMessage" class="text-destructive">{{ errorMessage }}</p>
</Transition>
<Button
type="submit"
variant="secondary"
:disabled="isSaving || isTesting"
>
Enregistrer
<LoadingSpinner v-if="isSaving" />
</Button>
<Button
variant="outline"
:disabled="isSaving || isTesting"
@click.prevent="testJob"
>
Tester
<LoadingSpinner v-if="isTesting" />
</Button>
</form>
</template>
<style lang="scss" scoped>
/* we will explain what these classes do next! */
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import Checkbox from '@/Components/ui/checkbox/Checkbox.vue';
import VModelCheckbox from '@/Components/ui/checkbox/VModelCheckbox.vue';
import Input from '@/Components/ui/input/Input.vue';
import { Label } from '@/Components/ui/label';
import Description from '@/Components/ui/text/Description.vue';
import { JobInfo } from '@/types/Jobs/job';
import { watch } from 'vue';
const props = defineProps<{
jobInfo: JobInfo;
}>();
const jobInfoType = props.jobInfo.job_info_type.name;
</script>
<template>
<div>
<Label :for="'' + jobInfo.id" class="text">{{ jobInfo.name }}<span v-if="jobInfo.is_required" class="cursor-help" title="Requis" aria-label="Requis">*</span></Label>
<Description>{{ jobInfo.description }}</Description>
<Input v-if="jobInfoType != 'checkbox'" :type="jobInfoType" :id="'' + jobInfo.id" :name="'' + jobInfo.id" :placeholder="jobInfo.placeholder" v-model="jobInfo.value as string" :required="jobInfo.is_required" />
<VModelCheckbox v-else :id="'' + jobInfo.id" :class="''" v-model="jobInfo.value as boolean" />
</div>
</template>

View File

@ -8,7 +8,7 @@ defineProps<{
</script>
<template>
<MainNavItem :link="`/job/${job.id}`">
<MainNavItem :link="`/jobs/${job.id}`">
{{ job.name }}
</MainNavItem>
</template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils.ts'
import { cn } from '@/lib/utils'
import { Primitive, type PrimitiveProps } from 'radix-vue'
import { type ButtonVariants, buttonVariants } from '.'

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils.ts'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils.ts'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils.ts'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils.ts'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils.ts'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils.ts'
import { cn } from '@/lib/utils';
const props = defineProps<{
class?: HTMLAttributes['class']

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from 'radix-vue'
import { cn } from '@/lib/utils'
import { Check } from 'lucide-vue-next'
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'radix-vue'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
v-bind="forwarded"
:class="
cn('peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
props.class)"
>
<CheckboxIndicator class="flex h-full w-full items-center justify-center text-current">
<slot>
<Check class="h-4 w-4" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
const props = withDefaults(defineProps<{
class?: string;
}>(), {
class: '',
});
const model = defineModel<boolean>({type: Boolean, default: false});
</script>
<template>
<input type="checkbox" :checked="model" @click="() => (model = !model)" class="peer h-7 w-7 shrink-0 rounded-sm border border-gray ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 checked:bg-secondary checked:text-dark-green transition cursor-pointer">
</template>

View File

@ -0,0 +1 @@
export { default as Checkbox } from './Checkbox.vue'

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import { Loader2 } from 'lucide-vue-next'
import type { PrimitiveProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { spinnerVariants, type SpinnerVariants } from '.'
interface Props extends PrimitiveProps {
size?: SpinnerVariants['size']
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
:class="cn(spinnerVariants({ size }), props.class)"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
</template>
<style scoped></style>

View File

@ -0,0 +1,19 @@
import { cva, type VariantProps } from 'class-variance-authority'
export const spinnerVariants = cva('animate-spin lucide lucide-loader-circle-icon', {
variants: {
size: {
default: 'w-4 h-4 m-2',
xs: 'w-1 h-1 m-1',
sm: 'w-2 h-2 m-1',
lg: 'w-7 h-7 m-3',
xl: 'w-12 h-12 m-4',
icon: 'w-10 h-10 m-4'
}
},
defaultVariants: {
size: 'default'
}
})
export type SpinnerVariants = VariantProps<typeof spinnerVariants>

View File

@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { useVModel } from '@vueuse/core'
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input v-model="modelValue" :class="cn('flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)">
</template>

View File

@ -0,0 +1 @@
export { default as Input } from './Input.vue'

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { Label, type LabelProps } from 'radix-vue'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<Label
v-bind="delegatedProps"
:class="
cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@ -0,0 +1 @@
export { default as Label } from './Label.vue'

View File

@ -0,0 +1,3 @@
<template>
<p class="text-dark-green"><slot/></p>
</template>

View File

@ -4,12 +4,17 @@ import BaseLayout from "./BaseLayout.vue";
import MainNavJobLink from "@/Components/Layout/MainNav/MainNavJobLink.vue";
import { Job } from "@/types/Jobs/job";
import MainNavItem from "@/Components/Layout/MainNav/MainNavItem.vue";
import { httpApi } from "@/lib/utils";
import { onMounted, ref } from "vue";
let jobs = [
{ id: 1, name: "Hellcase" },
{ id: 2, name: "Jeu gratuit Epic Games" },
{ id: 3, name: "Envoyer un post instagram" },
];
const jobs = ref<Job[]>([]);
async function fetchJobs() {
let jobsRaw = await httpApi<Job[]>("/jobs");
jobs.value = jobsRaw.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
}
onMounted(fetchJobs);
</script>
<template>
@ -19,16 +24,16 @@ let jobs = [
<ul class="flex flex-col gap-2">
<MainNavItem link="/"> Accueil </MainNavItem>
<MainNavJobLink
:job="job as Job"
v-for="job in jobs"
:key="job.id"
:job="job"
/>
</ul>
</nav>
</ScrollArea>
<ScrollArea class="flex-1 h-full overflow-auto">
<main>
<main class="p-3">
<slot />
</main>
</ScrollArea>

View File

@ -1,10 +1,12 @@
<script setup lang="ts">
import JobCard from '../Components/ui/job/JobCard.vue'
import JobForm from '../Components/Layout/Job/JobForm.vue'
import JobCard from '../Components/Layout/Job/JobCard.vue'
import { Job } from "@/types/Jobs/job";
import { Head } from "@inertiajs/vue3";
defineProps<{
job: Job;
error?: string;
}>();
</script>
@ -12,4 +14,6 @@ defineProps<{
<Head title="Welcome" />
<JobCard :job="job" />
<JobForm :job="job" :error="error" />
</template>

View File

@ -4,3 +4,14 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export async function httpApi<T>(route: string): Promise<T> {
let response = await fetch(import.meta.env.VITE_APP_URL + "/api" + route);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}

View File

@ -3,5 +3,39 @@ export type Job = {
name: string;
description: string;
is_active: boolean;
job_infos: JobInfo[];
created_at: Date;
}
export type JobInfo = {
id: number;
name: string;
description: string;
placeholder: string;
value: string | boolean;
is_required: boolean;
job_info_type: JobInfoType;
job_id: number;
}
export type JobInfoType = {
id: number;
name: string;
created_at: Date;
}
export type JobRunArtifact = {
jobId: number;
artifacts: JobArtifact[];
success: boolean;
}
export type JobArtifact = {
name: string;
content: string;
}

View File

@ -12,7 +12,11 @@
<!-- Scripts -->
@routes
@vite(['resources/js/app.ts', "resources/js/Pages/{$page['component']}.vue"])
@if(config('app.env') === 'production')
@vite(['resources/js/app.ts'])
@else
@vite(['resources/js/app.ts', "resources/js/Pages/{$page['component']}.vue"])
@endif
@inertiaHead
</head>
<body class="font-sans antialiased">

19
routes/api.php Normal file
View File

@ -0,0 +1,19 @@
<?php
use App\Http\Controllers\JobController;
use App\Models\Job;
use App\Services\BrowserJobsInstances;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/jobs', function (Request $request) {
return response()->json(Job::all())->header('Cache-Control', 'public, max-age=30');
});
Route::get('/test/{id}', function (Request $request, $id, BrowserJobsInstances $BrowserJobsInstances) {
$log = $BrowserJobsInstances->getJobInstance($id)->execute();
return response()->json(['message' => 'Job ' . $id . ' ran : ' . $log]);
});
Route::get('jobs/{job}/test', [JobController::class, 'test'])->name('jobs.test');

View File

@ -1,15 +1,12 @@
<?php
use App\Http\Controllers\JobController;
use App\Models\Job;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::get('/', function () {
return Inertia::render('Home');
});
})->name('home');
Route::get("/job/{jobId}", function ($jobId) {
return Inertia::render('Job', [
'job' => Job::find($jobId)
]);
});
Route::resource('jobs', JobController::class)->only(['show', 'update']);

View File

@ -0,0 +1,21 @@
<?php
namespace Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
class ExampleTest extends DuskTestCase
{
/**
* A basic browser test example.
*/
public function testBasicExample(): void
{
$this->browse(function (Browser $browser) {
$browser->visit('/')
->assertSee('Laravel');
});
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
class HomePage extends Page
{
/**
* Get the URL for the page.
*/
public function url(): string
{
return '/';
}
/**
* Assert that the browser is on the page.
*/
public function assert(Browser $browser): void
{
//
}
/**
* Get the element shortcuts for the page.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@element' => '#selector',
];
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Page as BasePage;
abstract class Page extends BasePage
{
/**
* Get the global element shortcuts for the site.
*
* @return array<string, string>
*/
public static function siteElements(): array
{
return [
'@element' => '#selector',
];
}
}

2
tests/Browser/console/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
tests/Browser/screenshots/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
tests/Browser/source/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

47
tests/DuskTestCase.php Normal file
View File

@ -0,0 +1,47 @@
<?php
namespace Tests;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Illuminate\Support\Collection;
use Laravel\Dusk\TestCase as BaseTestCase;
use PHPUnit\Framework\Attributes\BeforeClass;
abstract class DuskTestCase extends BaseTestCase
{
/**
* Prepare for Dusk test execution.
*/
#[BeforeClass]
public static function prepare(): void
{
if (! static::runningInSail()) {
static::startChromeDriver(['--port=9515']);
}
}
/**
* Create the RemoteWebDriver instance.
*/
protected function driver(): RemoteWebDriver
{
$options = (new ChromeOptions)->addArguments(collect([
$this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
'--disable-search-engine-choice-screen',
])->unless($this->hasHeadlessDisabled(), function (Collection $items) {
return $items->merge([
'--disable-gpu',
'--headless=new',
]);
})->all());
return RemoteWebDriver::create(
$_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515',
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY, $options
)
);
}
}

Binary file not shown.

View File

@ -0,0 +1 @@
sudo docker run --rm -it -p 3389:3389 -v ./undetectedChromedriver:/root/.local/share/undetected_chromedriver/ ultrafunk/undetected-chromedriver:latest

View File

@ -0,0 +1,10 @@
FROM selenium/standalone-chrome:108.0 AS final
COPY undetectedChromedriver/chromedriver-linux /bin/chromedriver
RUN mkdir -p /home/seluser/profile/
ENV TZ=Europe/Brussels
HEALTHCHECK --interval=30s --timeout=10s --retries=3 CMD curl -s http://localhost:4444/wd/hub/status | jq -e '.value.ready == true' || exit 1

View File

@ -0,0 +1,16 @@
services:
undetected-chromedriver:
build:
context: ../
dockerfile: undetectedChromedriver/seleniumChromedriverDockerfile
volumes:
- /tmp:/tmp
- chromeProfile:/home/seluser/profile/
shm_size: 2gb
tty: true
ports:
- "4444:4444"
- "7900:7900"
volumes:
chromeProfile: