Duplicitní odeslání formuláře vícenásobným kliknutím na tlačítko Odeslat

Alsatian
Člen | 164
+
0
-

Ahoj. Doteď jsem s tím neměl problém, ale u formuláře s přihláškou na tábor se nám stává, že je přihláška v systému opakovaně. Dopátral jsem, že opravdu stačí na tlačítko „Odeslat“ kliknout rychle třeba 2×, 3× a formulář se nám do databáze v uvedeném počtu propíše.

Používáme zde Nette ve verzi 3.0 a přihláška vypadá standardně…

public function create($taborId = NULL)
{
	$form = new Form;
    $form->addProtection('Vypršel časový limit, odešlete formulář znovu');

	// prvky formuláře jako:
	$form->addText('dite_jmeno', 'Jméno dítěte')
    	->setRequired('Zadejte jméno dítěte.');

	$form->addSubmit('send', 'Odeslat přihlášku');

    $form->onSuccess[] = [$this, 'editFormSucceeded'];
    return $form;
}

public function editFormSucceeded($form, $values)
{
	// zpracování formuláře - uložení dat do DB, odeslání potvrzení na email
	// ...

	// přesměrování na děkovací stránku - na základě hash se zobrazí některé údaje z přihlášky
	$p = $form->getPresenter();
    $p->flashMessage('Přihláška odeslána.', 'success');
    $p->redirect('odeslano', $prihlaska->hash);
}

Zkoušel jsem přidat javascript na tlačítko „Send“, které mu přidá vlastnost „disabled“, to se ale nastaví i v případě chybně vyplněného formuláře a ten již nelze odeslat.

Nikde jinde jsem se prozatím s duplicitními přihláškami v systému nesetkal (přijde tak cca každá desátá) a netušil jsem, že jde na odesílací tlačítko kliknout několikrát s tím, že se několikrát i odeslání provede.

Předem vám moc děkuji za nakopnutí :)

MajklNajt
Člen | 471
+
+2
-

toto je bežná vec a nesúvisí s Nette, treba to riešiť na strane klienta spôsobom, ako si už načrtol (s tým že tlačítku odstrániť disabled, ak form má chyby). Alebo na strane aplikácie to môžeš vyriešiť tak, že si bianko prihlášku (záznam v DB) vytvoríš v momente otvorenia formuláru a potom tento záznam pri odoslaní formuláru updatneš (2–3× update ti nespôsobí duplikáciu)

m.brecher
Generous Backer | 758
+
-1
-

@Alsatian běžně se na různých školeních webových aplikací doporučuje stránku po uložení do databáze přesměrovat, ale jak jsem si teď vyzkoušel, tak máš pravdu, že na rychlé klikání na odesílací tlačítko je přesměrování krátké.

Měl jsem úplně ten samý problém, ale duplicit bylo cca 1%.

Přesměrování neumí spolehlivě ošetřit duplicitní odeslání která jdou rychle za sebou.

Jedna strategie by byla ukládat timestamp objednávek a porovnat např. pro email zákazníka zda neexistuje záznam vytvořený např. před méně než 10 sec. se stejnými daty. To by asi vychytalo 99% duplicit.

Nebo 100% filtr, přidělit každé objednávce unikátní identifikátor (např. variabilní symbol), a ten zapisovat do tabulky objednávek, kde ten sloupec bude mít unikátní index. Vygenerovat identifikátor do vykresleného formuláře a opakovaný zápis selže protože ho nepustí unikátní index. Je potřeba si dát pozor, aby se zákazníkovi nezobrazilo chybové hlášení z duplicitního zápisu a on by si mohl myslet, že zápis selhal a půjde to ručně objednat znovu.

Alsatian
Člen | 164
+
0
-

@m.brecher – přesměrování na „děkovací“ stránku po odeslání formuláře máme.

No je to zapeklité. U malých – „rychlých“ formulářů to asi nenastane, ale pokud se zpracovává více dat a odeslání formuláře trochu pokulhá, uživatel klikne opakovaně a formulář se odešle 2×.

Nette formuláře používají pro svou ochranu „_token“, podívám se, jestli by nešlo tohle použít.

Neví někdo, jak nastavit „disabled“ tlačítku „Send“ jen při úspěšné validaci formuláře?

Editoval Alsatian (8. 1. 2023 22:51)

mystik
Člen | 291
+
0
-

Pokud jde o Nette vakidace tak muzes zjistit vysledke validace

if (Nette.validateForm(form)) {...}
Alsatian
Člen | 164
+
+1
-

Vyřešil jsem to pomocí uložení tokenu do vlastní proměnné v session a kontrolou její existence. Snad je to správné řešení a třeba někomu pomůže.

public function formSucceeded(Form $form, $values)
	{
		$token = $form->getHttpData($form::DATA_TEXT, '_token_');
		$section = $this->getPresenter()->getSession('reSendFormProtection');

		// Pokud jiz byl form odeslan - je token formulare v pomocne session a exit() ukonci opakovane zpracovani formulare.
		if ($section->get('token') == $token) exit();

		$section->set('token', $token);
		...
	}

EDIT: řešení funguje pro Nette 3.1, v Nette 3.0 exit() skončí bílou obrazovkou.
Zde jsem to vyřešil nakonec takto ($this->session Nette\Http\Session – si předám pomocí DI):

public function editFormSucceeded($form, $values)
{
	$token = $form->getHttpData($form::DATA_TEXT, '_token_');
	$formSection = $this->session->getSection('reSendFormProtection');
	if ($formSection->token == $token && isset($formSection->hash)) {
		$p = $form->getPresenter();
		$p->flashMessage('Přihláška odeslána.', 'success');
		$p->redirect('odeslano', $formSection->hash);
	}
	elseif ($formSection->token != $token) {
		$formSection->token = $token; // Pri prvnim odeslani formulare vlozime do session formularovy token
		$formSection->hash = self::newHash();
		...
		$p = $form->getPresenter();
		$p->flashMessage('Přihláška odeslána.', 'success');
		$p->redirect('odeslano', $formSection->hash);
	}
}

Editoval Alsatian (9. 1. 2023 0:22)

MajklNajt
Člen | 471
+
0
-

@Alsatian ako sa táto tvoja ochrana zachová v prípade, že človek urobí dvojklik a zlyhá zápis do databázy? užívateľ dostane hlášku „Přihláška odeslána“… myslím, že toto nie je správna cesta

piskotek
Člen | 32
+
0
-

ahoj, mě to řeší JS takto:

$('form').submit(function() {
			document.getElementById("odeslat").disabled = true;
			$('input[type=submit]').prop("disabled", "disabled");
		});
Milo
Nette Core | 1283
+
+1
-

exit() ukončí běh PHP. Stačí redirect, na to browser čeká.

Alsatian
Člen | 164
+
0
-

@MajklNajt máš naprostou pravdu, nevšiml jsem si toho. Tak stačí, když hash (ukládá se i do DB ke každé přihlášce) uložím do session až po úspěšném vložení do databáze. Nyní to mám v místě v horní části scriptu. Díky.

@piskotek jen tak pohledem na kód, nenastaví se disabled i když bude formulář chybně vyplněn? Potom už by nebylo možné jej odeslat po vyplnění nebo opravy chyb. Leda by se přidal časovač, který by tlačítko opět aktivoval třeba po určitém čase. Ale tohle řešení se mi úplně nelíbí. Ale díky.

@Milo to vyzkouším, to mě nenapadlo :) Ale jak psal MajklNajt, při chybě zápisu do DB by se „jako“ formulář odeslal. Asi upravím své druhé řešení a přidám vložení hash do session až za zápis do DB.

Alsatian
Člen | 164
+
0
-

Aby se neopakoval 2× kód pro přesměrování, tak stačí ukládat do session section s názvem třeba „formReSendProtection“ hodnotu tokenu. Pokud token v section máme, tak jsme formulář již odeslali. Token do ní uložíme po ošetření chyb a po úspěšném zápisu do DB.

public function formSucceeded(Form $form, $values)
{
	$token = $form->getHttpData($form::DATA_TEXT, '_token_');
	$reSendSection = $this->session->getSection('formReSendTest');

	if ($reSendSection->get('token') != $token) {
		... //zpracování formuláře, zápis do DB a pokud byl úspěšný, tak přidáme token do naší session sekce
		$reSendSection->set('token', $token);
	}

	... //redirect formuláře, ten se provede vždy, jen při opakovaném odeslání se kód IF výše vynechá.
}
Kamil Valenta
Člen | 762
+
+6
-

Kde se ten token vezme? Kde se ze sessiony vymaže? Nezdá se, že to umí rozlišit případy, kdy vyplním přihlášku za sebe a po uložení i za kamaráda, kterému zrovna nejde net?

Já nastavuji submitu disabled a pokud neprojde validace, tak disabled zase vypnu. Je to pohodlná cesta a nemusím nic řešit v každém formu samostatně.

Editoval Kamil Valenta (9. 1. 2023 10:58)

piskotek
Člen | 32
+
0
-

Alsatian napsal(a):

@MajklNajt máš naprostou pravdu, nevšiml jsem si toho. Tak stačí, když hash (ukládá se i do DB ke každé přihlášce) uložím do session až po úspěšném vložení do databáze. Nyní to mám v místě v horní části scriptu. Díky.

@piskotek jen tak pohledem na kód, nenastaví se disabled i když bude formulář chybně vyplněn? Potom už by nebylo možné jej odeslat po vyplnění nebo opravy chyb. Leda by se přidal časovač, který by tlačítko opět aktivoval třeba po určitém čase. Ale tohle řešení se mi úplně nelíbí. Ale díky.

@Milo to vyzkouším, to mě nenapadlo :) Ale jak psal MajklNajt, při chybě zápisu do DB by se „jako“ formulář odeslal. Asi upravím své druhé řešení a přidám vložení hash do session až za zápis do DB.

mám tam ještě live form validaci a pokud nejsou pole vyplněná tak se to vůbec neodešle a tlačítko je stále aktivní

jiri.pudil
Nette Blogger | 1028
+
0
-

U sessions taky pozor na zámky: v kódu máš dost nenápadně skrytou závislost na tom, že použitá implementace sessionu zamyká. Výchozí implementace v PHP to sice dělá, ale pokud bys někdy přesunul sessions do úložiště, které třeba zámky vůbec nemá, vznikne ti v aktuálním kódu race condition.

m.brecher
Generous Backer | 758
+
0
-

@Milo

exit() ukončí běh PHP. Stačí redirect, na to browser čeká.

To jsem si do včera také myslel a řešil jsem duplicitní objednávky přesměrováním. Ze zpracování submitu formuláře vynechávám časově náročné operace (odesílání emailu + generování pdf), aby redirect byl okamžitý.

Včera jsem si vyzkoušel trojitý klik na odesílací tlačítko a v databázi byly 3 identické rezervace redirect/neredirect :) Nejsem znalec browserů, ale mám takovou představu, že trojklik odešle 3 za sebou rychle následující requesty, která založí na serveru 3 nezávislá vlákna a když první vlákno utneš exit() nebo redirect() tak to nevypne ty ostatní vlákna :(.

m.brecher
Generous Backer | 758
+
0
-

@KamilValenta

Já nastavuji submitu disabled a pokud neprojde validace, tak disabled zase vypnu.

Ano, mě tohle přijde jako nejlepší řešení – jednoduché a dostatečně spolehlivé. Spolehlivě zamezit duplicitě submitu formuláře by měl mít vyřešen každý formulář.

Nepostnul by Jsi sem prosím ukázku kódu?

Milo
Nette Core | 1283
+
+1
-

m.brecher napsal(a):

@Milo

exit() ukončí běh PHP. Stačí redirect, na to browser čeká.

To jsem si do včera také myslel a řešil jsem duplicitní objednávky přesměrováním. Ze zpracování submitu formuláře vynechávám časově náročné operace (odesílání emailu + generování pdf), aby redirect byl okamžitý.

Včera jsem si vyzkoušel trojitý klik na odesílací tlačítko a v databázi byly 3 identické rezervace redirect/neredirect :) Nejsem znalec browserů, ale mám takovou představu, že trojklik odešle 3 za sebou rychle následující requesty, která založí na serveru 3 nezávislá vlákna a když první vlákno utneš exit() nebo redirect() tak to nevypne ty ostatní vlákna :(.

To jo, ale já to psal v kontextu, kdy si už duplicitu validuje přes session.

Milo
Nette Core | 1283
+
0
-

jiri.pudil napsal(a):

…pokud bys někdy přesunul sessions do úložiště, které třeba zámky vůbec nemá, vznikne ti v aktuálním kódu race condition.

Taková úložiště jsou? Kdy není zápis do session atomický?

Milo
Nette Core | 1283
+
+1
-

Pokud by se zamezení vytvoření duplicit mělo vyřešit pouze na backendu, shrnul bych to asi takhle:

1. Potřebuješ unikátní identifikátor – snadno

$form->addHidden('uid', Random::generate());

2. Potřebuješ zámek při zápisu a ověřit, že UID nebylo použito – těžko

Používám PostgreSQL a vyřešil bych to takhle:

ad 1.

$form->addHidden('id', $dibi->query('SELECT nextval(?)', 'form_id')->fetchSingle());

ID bude vždy unikátní, na sekvence se nevztahuje transakce.

ad 2.

$dibi->query('INSERT INTO reg %v', $form->values, 'ON CONFLICT (id) DO NOTHING');

Duplicitní volání neudělá INSERT, ale NOTHING. Lze zobrazit success. Pokud INSERT selže, duplicitní INSERT také selže. Lze zobrazit fail.

Marek Bartoš
Nette Blogger | 1167
+
+1
-

Taková úložiště jsou? Kdy není zápis do session atomický?

Bohužel, zámky jsou storage specific a tak custom implementace nemusí mít zámky vůbec. U nativní memcached se zámky dají vypnout, u nativní redis se musí zapnout, symfony redis nemá zámky vůbec.

Michalek
Člen | 210
+
0
-

Já mám ve formuláři skryté pole s uniqid, v databázi mám ten sloupec jako unikátní a když to odešlu omylem dvakrát po sobě, vyhodí mi to 500, protože už se to do databáze neuloží.

Jasně, je to moje administrace, ale ta 500 se dá pořešit hláškou, u mě je to spíš „příjemný omyl“ než požadovaná funkce :)

Editoval Michalek (10. 1. 2023 11:05)

akmt
Člen | 20
+
0
-

Jen uvažuji – uložit do session hash z formulářových hodnot v metodě, která se zavolá $form->onSuccess[]. V případě, že další volání $form->onSuccess[] zjistí, že hash těchto hodnot je již uložen, tak se již duplicitní hodnoty neuloží…?

Alsatian
Člen | 164
+
0
-

@KamilValenta

Já nastavuji submitu disabled a pokud neprojde validace, tak disabled zase vypnu. Je to pohodlná cesta a nemusím nic řešit v každém formu samostatně.

Taky bych poprosil o ukázku kódu. Znovu jsem na tento problém narazil a bylo by lepší vyřešit jej pro všechny formuláře na jednom místě. Předem děkuji.

Kamil Valenta
Člen | 762
+
0
-

Zjednodušeně:

$('form').submit(function() {
    $('input[type=submit]').prop("disabled", "disabled");
});

A když neprojde validace:

$('input[type=submit]').each(function() {
    $(this).removeAttr('disabled');
});
Alsatian
Člen | 164
+
0
-

Kamil Valenta napsal(a):

Zjednodušeně:

$('form').submit(function() {
    $('input[type=submit]').prop("disabled", "disabled");
});

A když neprojde validace:

$('input[type=submit]').each(function() {
    $(this).removeAttr('disabled');
});

Zeptám se hloupě… kam to mám umístit? Přidal jsem to zkusmo na stránku s formulářem. Při načtení stránky se provede „odznačení“ všech submit tlačítek. Takže asi správně. Pokud kliknu na Odeslat ve formuláři, který je třeba nevyplněný, tak po zavření JS Alert okna s chybou zůstane tlačítko jako Disabled. Co by přimělo znovu JS načíst a tím jej „odznačit“?

EDIT:
Znovu „oživení“ odesílacího tlačítka jsem nakonec přidal do netteForms.js na událost kliknutí na tlačítko „button“ (OK).
Jde tohle „rozšíření“ přidat do vlastního JS nebo to musím umístit do netteForms.js? :) Díky.

Editoval Alsatian (27. 11. 2023 19:21)

Kamil Valenta
Člen | 762
+
0
-

Alsatian napsal(a):

Při načtení stránky se provede „odznačení“ všech submit tlačítek. Takže asi správně.

To asi moc správně není. Disablovat by se měl submit při pokusu o odeslání.

Znovu „oživení“ odesílacího tlačítka jsem nakonec přidal do netteForms.js na událost kliknutí na tlačítko „button“ (OK).
Jde tohle „rozšíření“ přidat do vlastního JS nebo to musím umístit do netteForms.js? :) Díky.

Můžeš si přepsat

Nette.showFormErrors = function (form, errors) {
	// vlastní reakce na validaci
	// + zrušení disabled submitu
};
Alsatian
Člen | 164
+
0
-

Kamil Valenta napsal(a):

Můžeš si přepsat

Nette.showFormErrors = function (form, errors) {
	// vlastní reakce na validaci
	// + zrušení disabled submitu
};

Když navěsím funkci znovu oživení tlačítek do Nette.showFormErrors, tak se nic nestane a tlačítko pro odeslán formuláře zůstane ve stavu Disabled. Když přidám “znovu oživení” tlačítek do Nette.showModal, konkrétně pod

button.onclick = function () {}

tak se tlačítka sice po kliknutí na OK v chybovém dialogu oživí, ale pokud zavřu dialog klávesou ESC, tak se příkaz nevykoná…
Neposkytl by někdo funkční řešení :) Je to trápení :)

Editoval Alsatian (27. 11. 2023 21:12)

Kamil Valenta
Člen | 762
+
0
-

Já Ti to kopíruju z funkčního řešení. Nikdo z nás neví, jak a kde si to adaptuješ. Tak to trochu upřesni, ukaž na co sis přepsal Nette.showFormErrors, v jakém souboru, jak a kdy ho includuješ do šablony…

Alsatian
Člen | 164
+
0
-

Nevím, jestli je to správné řešení, ale pro mé Nette 3.1 funguje a děkuji za nakopnutí @KamilValenta
I když samotné tvé řešení přidání UN disabled do Nette.showFormErrors mi neudělá nic.

Pomohla mi následující úprava souboru netteForms.js (nejnovější verze co jsem na webu našel):

/**
	 * Display modal window.
	 */
	Nette.showModal = function(message, onclose) {
		var dialog = document.createElement('dialog');

		dialog.addEventListener("close", (event) => { // opětovná aktivace tlačítka při zavření dialogu klávesou ESC
			$('input[type=submit]').prop("disabled", false);
		});

		if (!dialog.showModal) {
			alert(message);
			onclose();
			return;
		}

		var style = document.createElement('style');
		style.innerText = '.netteFormsModal { text-align: center; margin: auto; border: 3px solid #F07100; padding: 1rem; border-radius: 5px; box-shadow: 0px 4px 6px rgba(0, 0, 0, .25); } .netteFormsModal button { padding: .1em 2em }';

		var button = document.createElement('button');
		button.innerText = 'OK';
		button.onclick = function () {
			dialog.remove();
			onclose();
			$('input[type=submit]').prop("disabled", false); // odstranění disabled submit form tlačítka při zavření dialogu kliknutím na "OK"
		};

		dialog.setAttribute('class', 'netteFormsModal');
		dialog.innerText = message + '\n\n';
		dialog.append(style, button);
		document.body.append(dialog);
		dialog.showModal();
	};

Script by šel tak jak je vytáhnout do samostatného JS, což je určitě lepší řešení, protože pokud budete aktualizovat soubor netteForm.js, tak o změny přijdete.

Do svého main.js souboru jsem přidal funkci, která po klinutí na tlačítko odeslat ve formuláři jej dočasně zneaktivní přidáním „disabled“.

Soubor main.js

$(document).ready(function() {
	$('form').submit(function() {
		$('input[type=submit]').prop("disabled", "disabled");
	});
});

PS: je to ale něco, na co se v mém případě nespoléhám a mám aplikovanou ještě ochranu násobného odeslání formuláře pomocí session, viz komunikace výše. Upřímně bych nějakou takovou ochranu uvítal přímo už v Nette, protože i když Nette poskytuje úžasnou ochranu před spamy, stává se mi tu ta tam, že z kontaktního formuláře přijde násobně odeslaný spam a to jen proto, že jej někdo takto odklikal rychle za sebou :)

Editoval Alsatian (27. 11. 2023 22:25)

David Grudl
Nette Core | 8129
+
+2
-

Nebylo by nejlepší nějakou podporu přidat přímo do netteForms.js?

Kamil Valenta
Člen | 762
+
0
-

Alsatian napsal(a):

I když samotné tvé řešení přidání UN disabled do Nette.showFormErrors mi neudělá nic.

Znovu říkám, že nikdo z nás netuší jak a kam jsi to přidal. Nette.showFormErrors jsi nám neukázal, otázky jsi nezodpověděl…

Kamil Valenta
Člen | 762
+
+2
-

David Grudl napsal(a):

Nebylo by nejlepší nějakou podporu přidat přímo do netteForms.js?

Pokud, tak by měla být ifovaná nějakým data- atributem. Protože je hodně submitů, které disablovat nechceš, i když je form validní…