Compare commits
3 Commits
070235e011
...
jobs/hellc
Author | SHA1 | Date | |
---|---|---|---|
e8b9517664 | |||
cfbae6ddbf | |||
4368aae6c4 |
@@ -25,12 +25,12 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
private const STEAM_LOGIN_THRESHOLD = 5 * 60; // 5 minutes
|
||||
private const APPROXIMATIVE_RUNNING_MINUTES = 2;
|
||||
|
||||
private JobRun $jobRun;
|
||||
protected JobRun $jobRun;
|
||||
|
||||
public function __construct()
|
||||
public function __construct($jobId = 2)
|
||||
{
|
||||
Log::info("Constructing HellcaseJob");
|
||||
parent::__construct(2);
|
||||
parent::__construct($jobId);
|
||||
}
|
||||
|
||||
public function run(Browser $browser): ?JobRun
|
||||
@@ -89,7 +89,7 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
}
|
||||
}
|
||||
|
||||
private function signin(Browser $browser)
|
||||
protected function signin(Browser $browser)
|
||||
{
|
||||
try {
|
||||
$browser->clickAtXPath('//button[.//span[text() = "Sign in"]]');
|
||||
@@ -390,7 +390,7 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
$browser->clickAtXPath('//*[contains(text(), "Edit Profile")]');
|
||||
}
|
||||
|
||||
private function removePopups(Browser $browser)
|
||||
protected function removePopups(Browser $browser)
|
||||
{
|
||||
// $browser->script('document.querySelector("div.app-modal")[0].remove();');
|
||||
// $browser->driver->executeScript('document.querySelector("div.app-modal")[0].remove();');
|
||||
|
145
app/Browser/Jobs/HellcaseBattles/HellcaseBattlesJob.php
Normal file
145
app/Browser/Jobs/HellcaseBattles/HellcaseBattlesJob.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\HellcaseBattles;
|
||||
|
||||
use App\Browser\Jobs\Hellcase\HellcaseJob;
|
||||
use App\Models\HellcaseBattle;
|
||||
use App\Models\Job;
|
||||
use App\Models\JobRun;
|
||||
use App\Notification\Notifications\JobDebugNotification;
|
||||
use App\Notification\Providers\AllNotification;
|
||||
use Exception;
|
||||
use Facebook\WebDriver\WebDriver;
|
||||
use Facebook\WebDriver\WebDriverBy;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
class HellcaseBattlesJob extends HellcaseJob implements ShouldBeUniqueUntilProcessing
|
||||
{
|
||||
private Collection $jobInfos;
|
||||
private array $battlesToAdd = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
Log::info("Constructing HellcaseBattlesJob");
|
||||
parent::__construct(3);
|
||||
}
|
||||
|
||||
public function run(Browser $browser): ?JobRun
|
||||
{
|
||||
$this->jobInfos = Job::find($this->jobId)->jobInfosTable();
|
||||
Log::info("Running HellcaseBattlesJob");
|
||||
$this->jobRun = new JobRun([
|
||||
"job_id" => $this->jobId,
|
||||
"success" => false,
|
||||
]);
|
||||
$this->jobRun->save();
|
||||
|
||||
$browser->visit('https://hellcase.com');
|
||||
$browser->waitForText("CASES", 30, true);
|
||||
$this->removePopups($browser);
|
||||
sleep(5);
|
||||
$this->signin($browser);
|
||||
|
||||
$this->saveInterestingBattles($browser);
|
||||
|
||||
$this->sendFinishedBattles($browser);
|
||||
|
||||
$this->createNewBattles();
|
||||
|
||||
$this->jobRun->success = true;
|
||||
$this->jobRun->save();
|
||||
|
||||
Log::info("HellcaseBattlesJob run ended");
|
||||
|
||||
return $this->jobRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current cases battles to database for later processing
|
||||
* @param \Laravel\Dusk\Browser $browser
|
||||
* @return void
|
||||
*/
|
||||
private function saveInterestingBattles(Browser $browser)
|
||||
{
|
||||
$battleIndex = 0; // Index of the battle to get info from
|
||||
$running = true;
|
||||
while ($running) {
|
||||
$browser->visit('https://hellcase.com/casebattle');
|
||||
$browser->waitForText("CASES", 30, true);
|
||||
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "I hate niggers"));
|
||||
|
||||
// Sort by price
|
||||
try {
|
||||
$sortByPriceDiv = $browser->driver->findElement(WebDriverBy::xpath("//*[span[contains(text(), 'Value')]]"));
|
||||
$sortByPriceDiv->click();
|
||||
} catch (Exception $e) {
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to sort by price"));
|
||||
return;
|
||||
}
|
||||
|
||||
sleep(5);
|
||||
|
||||
$battles = $browser->driver->findElements(WebDriverBy::xpath("//*[contains(@class, 'casebattle-table__item')]"));
|
||||
$battle = $battles[$battleIndex];
|
||||
$battleIndex++;
|
||||
$browser->scrollIntoView(".casebattle-table__item:nth-child(" . max($battleIndex -1, 1) . ")");
|
||||
sleep(2);
|
||||
$battleValue = floatval(
|
||||
explode(
|
||||
"\n",
|
||||
$battle->findElement(WebDriverBy::xpath("./div/div[contains(@class, 'core-price')]"))->getDomProperty("innerText")
|
||||
)[1]
|
||||
);
|
||||
|
||||
if ($battleValue < floatval($this->jobInfos->get("hellcase_battles_minimum_value"))) {
|
||||
$running = false;
|
||||
break;
|
||||
}
|
||||
|
||||
$battleLinkButton = $battle->findElement(WebDriverBy::xpath('./div//button[text() = "watch"]'));
|
||||
$battleLinkButton->sendKeys("\n");
|
||||
sleep(3);
|
||||
$battleLink = $browser->driver->getCurrentURL();
|
||||
|
||||
$this->battlesToAdd[$battleLink] = $battleValue;
|
||||
}
|
||||
}
|
||||
|
||||
private function sendFinishedBattles(Browser $browser) {
|
||||
// foreach battle that we didn"t already planned to add with $this->battlesToAdd
|
||||
foreach (HellcaseBattle::all() as $battle) {
|
||||
dump($battle);
|
||||
if (!array_key_exists($battle->getUrl(), $this->battlesToAdd)) {
|
||||
dump("finished");
|
||||
$browser->visit($battle->getUrl());
|
||||
|
||||
try {
|
||||
$browser->waitForText("Started at");
|
||||
// Send the battle
|
||||
$this->sendBattle($browser, $battle);
|
||||
} catch (Exception $e) { // Battle is not finished or error (like battle cancelled)
|
||||
}
|
||||
|
||||
$battle->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sendBattle(Browser $browser, HellcaseBattle $battle) {
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Battle sent" . $battle->getUrl()));
|
||||
}
|
||||
|
||||
private function createNewBattles() {
|
||||
foreach ($this->battlesToAdd as $battleLink => $battleValue) {
|
||||
$battleLink = explode("/", $battleLink);
|
||||
HellcaseBattle::firstOrCreate([
|
||||
"battle_id" => $battleLink[count($battleLink) - 1],
|
||||
"value" => $battleValue,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
17
app/Models/HellcaseBattle.php
Normal file
17
app/Models/HellcaseBattle.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class HellcaseBattle extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
"battle_id",
|
||||
"value",
|
||||
];
|
||||
|
||||
public function getUrl() {
|
||||
return "https://hellcase.com/casebattle/{$this->battle_id}";
|
||||
}
|
||||
}
|
@@ -21,6 +21,16 @@ class Job extends Model
|
||||
return $this->hasMany(JobInfo::class)->with("jobInfoType")->orderBy("created_at");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an associative collection of the job infos with their values
|
||||
* @return \Illuminate\Database\Eloquent\Collection<string, string>>
|
||||
*/
|
||||
public function jobInfosTable() {
|
||||
return $this->jobInfos->mapWithKeys(function ($jobInfo) {
|
||||
return [$jobInfo->key => $jobInfo->value];
|
||||
});
|
||||
}
|
||||
|
||||
public function jobRuns()
|
||||
{
|
||||
return $this->hasMany(JobRun::class)->orderBy("created_at");
|
||||
|
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Job;
|
||||
use App\Models\JobInfo;
|
||||
use App\Models\JobInfoType;
|
||||
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
|
||||
{
|
||||
$newJobId = 3;
|
||||
Job::forceCreate([
|
||||
"id" => $newJobId,
|
||||
"name" => "Hellcase Battles",
|
||||
"description" => "Envoie les meilleures battles d'Hellcase",
|
||||
]);
|
||||
|
||||
JobInfo::forceCreate([
|
||||
"key" => "hellcase_battles_discord_webhook_url",
|
||||
"name" => "Webhook Discord",
|
||||
"description" => "Le lien discord webhook utilisé pour envoyer les meilleures battles d'Hellcase.\nSi aucun n'est spécifié, le webhook Discord des paramètres généraux sera utilisé.",
|
||||
"placeholder" => "https://discord.com/api/webhooks/...",
|
||||
"is_required" => false,
|
||||
"job_info_type_id" => 4,
|
||||
"job_id" => $newJobId,
|
||||
]);
|
||||
|
||||
JobInfoType::forceCreate([
|
||||
"id" => 5,
|
||||
"name" => "number",
|
||||
]);
|
||||
JobInfoType::forceCreate([
|
||||
"id" => 6,
|
||||
"name" => "boolean",
|
||||
]);
|
||||
|
||||
JobInfo::forceCreate([
|
||||
"key" => "hellcase_battles_minimum_value",
|
||||
"name" => "Valeur minimum des battles",
|
||||
"description" => "La valeur minimale qu'une battle doit avoir pour être envoyée, en euros.",
|
||||
"placeholder" => "1000",
|
||||
"job_info_type_id" => 5,
|
||||
"job_id" => $newJobId,
|
||||
]);
|
||||
|
||||
JobInfo::forceCreate([
|
||||
"key" => "hellcase_battles_allow_bots",
|
||||
"name" => "Autoriser les battles avec bots",
|
||||
"description" => "Envoyer les battles avec un seul joueur et des bots.",
|
||||
"is_required" => false,
|
||||
"job_info_type_id" => 6,
|
||||
"job_id" => $newJobId,
|
||||
]);
|
||||
|
||||
Schema::create('hellcase_battles', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->string("battle_id")->unique();
|
||||
$table->float("value");
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Job::where("id", 3)->delete();
|
||||
JobInfo::where("job_id", 3)->delete();
|
||||
JobInfoType::whereIn("id", [5, 6])->delete();
|
||||
|
||||
Schema::dropIfExists('hellcase_battles');
|
||||
}
|
||||
};
|
@@ -24,6 +24,7 @@
|
||||
"vue-tsc": "^2.0.24"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
50
pnpm-lock.yaml
generated
50
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.21.2
|
||||
version: 8.21.2(vue@3.5.13(typescript@5.7.3))
|
||||
'@vueuse/core':
|
||||
specifier: ^12.5.0
|
||||
version: 12.7.0(typescript@5.7.3)
|
||||
@@ -26,6 +29,9 @@ importers:
|
||||
radix-vue:
|
||||
specifier: ^1.9.13
|
||||
version: 1.9.16(vue@3.5.13(typescript@5.7.3))
|
||||
reka-ui:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))
|
||||
tailwind-merge:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
@@ -425,9 +431,19 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1'
|
||||
|
||||
'@tanstack/table-core@8.21.2':
|
||||
resolution: {integrity: sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/virtual-core@3.13.2':
|
||||
resolution: {integrity: sha512-Qzz4EgzMbO5gKrmqUondCjiHcuu4B1ftHb0pjCut661lXZdGoHeze9f/M8iwsK1t5LGR6aNuNGU7mxkowaW6RQ==}
|
||||
|
||||
'@tanstack/vue-table@8.21.2':
|
||||
resolution: {integrity: sha512-KBgOWxha/x4m1EdhVWxOpqHb661UjqAxzPcmXR3QiA7aShZ547x19Gw0UJX9we+m+tVcPuLRZ61JsYW47QZFfQ==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
vue: '>=3.2'
|
||||
|
||||
'@tanstack/vue-virtual@3.13.2':
|
||||
resolution: {integrity: sha512-z4swzjdhzCh95n9dw9lTvw+t3iwSkYRlVkYkra3C9mul/m5fTzHR7KmtkwH4qXMTXGJUbngtC/bz2cHQIHkO8g==}
|
||||
peerDependencies:
|
||||
@@ -952,6 +968,9 @@ packages:
|
||||
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ohash@1.1.6:
|
||||
resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==}
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
@@ -1054,6 +1073,11 @@ packages:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
reka-ui@2.1.0:
|
||||
resolution: {integrity: sha512-w4kEDEyXhIqv4QeFJeiuBc4mQP37hH/UTRpEb9dMbPdR49JG5TcV/s0+ntNRONUUW4LDLX7E1ZPcwBw5hnu0yw==}
|
||||
peerDependencies:
|
||||
vue: '>= 3.2.0'
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1681,8 +1705,15 @@ snapshots:
|
||||
mini-svg-data-uri: 1.4.4
|
||||
tailwindcss: 3.4.17
|
||||
|
||||
'@tanstack/table-core@8.21.2': {}
|
||||
|
||||
'@tanstack/virtual-core@3.13.2': {}
|
||||
|
||||
'@tanstack/vue-table@8.21.2(vue@3.5.13(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.2
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
'@tanstack/vue-virtual@3.13.2(vue@3.5.13(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.2
|
||||
@@ -2224,6 +2255,8 @@ snapshots:
|
||||
|
||||
object-inspect@1.13.3: {}
|
||||
|
||||
ohash@1.1.6: {}
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
@@ -2321,6 +2354,23 @@ snapshots:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
reka-ui@2.1.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)):
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.6.13
|
||||
'@floating-ui/vue': 1.1.6(vue@3.5.13(typescript@5.7.3))
|
||||
'@internationalized/date': 3.7.0
|
||||
'@internationalized/number': 3.6.0
|
||||
'@tanstack/vue-virtual': 3.13.2(vue@3.5.13(typescript@5.7.3))
|
||||
'@vueuse/core': 12.7.0(typescript@5.7.3)
|
||||
'@vueuse/shared': 12.7.0(typescript@5.7.3)
|
||||
aria-hidden: 1.2.4
|
||||
defu: 6.1.4
|
||||
ohash: 1.1.6
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- typescript
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
resolve@1.22.10:
|
||||
|
@@ -18,7 +18,7 @@ const jobInfoType = props.jobInfo.job_info_type.name;
|
||||
<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" />
|
||||
<Input v-if="['text', 'email', 'password', 'url', 'number'].includes(jobInfoType)" :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>
|
||||
|
||||
|
@@ -10,19 +10,21 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2>Ancien jobs</h2>
|
||||
<ScrollArea class="min-h-[300px] max-h-[20vh] overflow-auto pr-2">
|
||||
<Accordion type="multiple" collapsible>
|
||||
<JobRunItem
|
||||
:jobRun="jobRun"
|
||||
v-for="jobRun in job.job_runs.sort((a, b) => {
|
||||
return (
|
||||
new Date(b.created_at).getTime() -
|
||||
new Date(a.created_at).getTime()
|
||||
);
|
||||
})"
|
||||
:key="jobRun.id"
|
||||
/>
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
<div v-if="job.job_runs.length > 0">
|
||||
<h2>Ancien jobs</h2>
|
||||
<ScrollArea class="min-h-[300px] max-h-[20vh] overflow-auto pr-2">
|
||||
<Accordion type="multiple" collapsible>
|
||||
<JobRunItem
|
||||
:jobRun="jobRun"
|
||||
v-for="jobRun in job.job_runs.sort((a, b) => {
|
||||
return (
|
||||
new Date(b.created_at).getTime() -
|
||||
new Date(a.created_at).getTime()
|
||||
);
|
||||
})"
|
||||
:key="jobRun.id"
|
||||
/>
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</template>
|
||||
|
@@ -12,6 +12,7 @@ Route::get('/jobs', function (Request $request) {
|
||||
|
||||
Route::get('/test/{id}', function (Request $request, $id, BrowserJobsInstances $BrowserJobsInstances) {
|
||||
$log = $BrowserJobsInstances->getJobInstance($id)->execute();
|
||||
dump($log);
|
||||
return response()->json(['message' => 'Job ' . $id . ' ran', 'jobRun' => $log->load('artifacts')]);
|
||||
});
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Browser\Jobs\Hellcase\HellcaseJob;
|
||||
use App\Browser\Jobs\HellcaseBattles\HellcaseBattlesJob;
|
||||
use App\Jobs\PruneOldJobRuns;
|
||||
use App\Services\BrowserJobsInstances;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
@@ -21,3 +22,4 @@ Schedule::job(new PruneOldJobRuns)->monthly()->onOneServer()->withoutOverlapping
|
||||
// Jobs
|
||||
Schedule::job(new HellcaseJob)->daily()->onOneServer()->withoutOverlapping()->name('hellcase')->description('Hellcase job');
|
||||
// Schedule::job(new HellcaseJob)->everyMinute()->onOneServer()->withoutOverlapping()->name('hellcase')->description('Hellcase job');
|
||||
Schedule::job(new HellcaseBattlesJob)->hourly()->onOneServer()->withoutOverlapping()->name('hellcase_battles')->description('Hellcase battles job');
|
||||
|
3
todo.md
3
todo.md
@@ -13,6 +13,9 @@
|
||||
- Websocket installé
|
||||
- Serveur php plus propre (nginx, apache, n'importe)
|
||||
- Epic games
|
||||
Pas l'air possible avec cloudflare
|
||||
- Petit bug, quand l'on enregistre un formulaire avec une erreur, l'url a un argument GET ?error=mon%24erreur
|
||||
Du coup dans la nav le job actuel n'est plus reconnu
|
||||
|
||||
## Pour deploy Lama
|
||||
|
||||
|
Reference in New Issue
Block a user