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

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();
}
}
}