[PHP/MySQL] Lösung für Browser-Reload-Problem

ManicMarble

Erfahrenes Mitglied
Hallo zusammen,

ich habe heute ausnahmsweise mal keine Frage, sondern einen Lösungsansatz, zu dem ich gerne eine Diskussion anregen würde.

Ich hatte - wie einige andere - schön öfters das bekannte Problem, das entsteht, wenn der Anwender eine Formular-Seite in seinem Browser aktualisiert (Reload) oder per Zurück-Button auf eine solche zurückkehrt. Wurden mit dieser Seite bereits Daten abgeschickt, dann sagt z.B. der IE sowas wie "Aktuelle Seite nicht mehr gültig...", der Anwender kann aber das erneute Abschicken des Formulars erzwingen und wenn damit z.B. ein Datenbank-INSERT ausgeführt wurde, dann hat man jetzt 2 Datensätze. (Es gibt wirklich viele schlecht programmierte Foren bei denen genau durch diesen Effekt permanent Beiträge doppelt gepostet werden). Ich glaube es ist klar, was ich meine.

Natürlich gibt es einige Lösungsansätze. Die sauberste Lösung ist meines Erachtens immer die, wo die Auswertung eines Formulars nicht auf der Formular-Seite selbst passiert, also das Formular-Action-Ziel verweist auf einen reine PHP-Seite die nur DB-Aktionen ausführt und per header("Location:...") wieder nach wo anders hin umleitet - u.U. auch wieder zurück auf die ursprüngliche Formularseite. Das bringt aber auch Nachteile mit sich, z.B. stehen nach Rückkehr zum Formular wieder alle Felder und Select-Boxen etc. "auf Anfang" und man muss wieder extra was programmieren um diese wieder mit den vom User vorher gemachten Angaben zu füllen.

Ich wollte für ein Projekt (es geht dabei um Projektzeiterfassung) aus diversen Überlegungen heraus explizit Formular und Formular-Verarbeitung auf der selben Seite haben und dennoch sicher verhindern, dass Daten doppelt eingefügt werden. Ich habe dafür den Lösungsansatz "Eindeutige Transaktions-ID per Session" (der nicht auf allen Browsern und mit allen Einstellungen 100%ig sicher funktioniert) etwas abgewandelt. So sieht das jetzt aus:

Im Formular gibt es ein zusätzliches verstecktes Input-Feld namens "transid". Als Inhalt (value) wird bei jedem "normalen" Aufruf der Seite eine zufällige, (99.999....9%ig) eindeutige ID erzeugt:
Code:
<input name="transid" type="hidden" value="<?php srand((double)microtime()*1000000); echo md5(date("U")."TransId".rand(1, 1000000))."-FormularName"; ?>">
In der Datenbank gibt es eine eigene Tabelle für diese Transaktions-IDs:
Code:
+-------------+-------------+-------+---------------------+
|Feld         |Typ          |Null   |Standard             |
+-------------+-------------+-------+---------------------+
|id           |varchar(64)  |Nein   |                     |
|time         |timestamp    |Ja     |0000-00-00 00:00:00  |
|remoteip     |varchar(16)  |Ja     |NULL                 |
|scriptname   |text         |Ja     |NULL                 |
+-------------+-------------+-------+---------------------+
PRIMARY INDEX ist "id"
Nun habe ich in der Standard-Include-Datei (die in jedem PHP-Script verwendet wird und die noch viele andere Funktionen enthält) folgende Funktion geschrieben:
PHP:
// Checkt ab, ob in einer vorhandenen transid-Tabelle eine
// eindeutige Transaktions-ID (Parameter) bereits vorhanden ist.
// Wenn nicht, wird diese in diese Tabelle eingefügt und TRUE zurückgegeben.
// Wenn doch, wird FALSE zurückgegeben.
function bls_checkTransId($transid, $table = "transid", $clearTrash = true) {
  
  global $con;
  
  $sql = "SELECT id FROM $table WHERE id = '$transid'";
  $rst = mysql_query($sql, $con) or die("<p>Böses SQL-Statement:</p><p>$sql</p><p>".mysql_error($con)."</p>");
  if (mysql_num_rows($rst) > 0) {
    
    mysql_free_result($rst);
    return false;
    
  } else {
    
    mysql_free_result($rst);
    
    // Trash:
    if ($clearTrash) {
      // alles außer die letzten 10.000 Transaktionen wird gelöscht
      $sql = "SHOW TABLE STATUS LIKE '$table'";
      $rst = mysql_query($sql, $con) or die("<p>Böses SQL-Statement:</p><p>$sql</p><p>".mysql_error($con)."</p>");
      $row = mysql_fetch_assoc($rst);
      $sql = $row["Rows"];
      mysql_free_result($rst);
      if ($sql > 10500) {
        $sql = "DELETE FROM $table ORDER BY time LIMIT " . ($sql - 9500);
        mysql_query($sql, $con) or die("<p>Böses SQL-Statement:</p><p>$sql</p><p>".mysql_error($con)."</p>");
      }
    }
    
    $sql = "INSERT INTO $table SET id = '$transid', time = NOW(), remoteip = '".$_SERVER['REMOTE_ADDR']."', scriptname = '".$_SERVER['SCRIPT_FILENAME']."'";
    mysql_query($sql, $con) or die("<p>Böses SQL-Statement:</p><p>$sql</p><p>".mysql_error($con)."</p>");    
    return true;
    
  }
  
  return true;
}
Vor der eigentlichen Datenbank-Aktion (beispielsweise einem INSERT) wird jetzt diese Funktion aufgerufen und nur wenn sie "true" zurück liefert, wird die DB-Aktion ausgeführt:
PHP:
if (bls_checkTransId($_POST["transid"], "transid", true)) {
  $sql = "INSERT INTO tabelle SET irgendwas = 'sonstwas'";
  mysql_query($sql, $con) or die("Fehler: ...");
}
Damit wird also zunächst überprüft, ob das Formular wirklich "frisch" abgeschickt wurde (also mit neu generierter Transaktions-ID) oder ob es sich um einen Reload handelt (Transaktions-ID gab's schon mal).
Wenn es "frisch" war, dann wird die neue Transaktions-ID in die Tabelle "transid" geschrieben - zusammen mit einigen weiteren nützlichen Infos, womit man gleich eine Art Log hat wo man nachschauen kann, von welchem Client aus wann irgendwelche Einträge gemacht wurde - und es wird "true" zurück geliefert, die DB-Aktion wird also ausgeführt.
Wenn ein Formular erneut gepostet wird, dann liefert die Funktion "false" und es erfolgt einfach keine DB-Aktion.
Damit die Log-Tabelle nicht zu voll wird, habe ich noch diese Trash-Geschichte eingebaut, sobald 10500 Datensätze existieren wird alles bis auf die letzten 9500 gelöscht (so findet diese Putzaktion nur alle 1000 Aufrufe statt).

Was haltet ihr davon?
Ist das "das Rad neu erfunden" oder "mit Kanonen auf Spatzen" oder gibt's da eine viel einfachere Lösung? Hat die Methode andere von mir nicht bedachte Haken? Funktionieren tut's jedenfalls. Ich bin mir aber eben selber nicht so ganz sicher, ob das der richtige Weg ist, deshalb würde mich die Meinung von ein paar Spezialisten interessieren.

Ach, und sorry, dass das Posting nun doch so lange geworden ist... ;)

Viele Grüße
Martin
 
Ich habe im zweiten Beitrag des folgenden Threads einen ähnlichen Lösungsansatz kurz beschrieben. Ich würde nicht den Umweg über die Datenbank gehen, sondern eher die eindeutige Transaktions-ID in der Session des Nutzers ablegen. Dadurch hat man zwar keine LOG-Funktion, allerdings wird diese auch oft nicht gebraucht.

--> http://www.tutorials.de/tutorials190725.html
 
Yep. Das ist im Prinzip der von mir erwähnte Lösungsansatz "Eindeutige Transaktions-ID per Session". So habe ich das bisher oft gemacht. Habe allerdings festgestellt, dass das nicht 100%ig sicher mit allen Browsern bzw. allen möglichen Browser-Einstellungen funktioniert (Mit Opera habe ich z.B. schon massive Probleme mit Sessions gehabt und auch im IE, wenn einer im Wahn an den Cookie-Einstellungen rumschraubt).
Für dieses Projekt dürfen einfach unter gar keinen Umständen doppelte Einträge entstehen und ich habe irgendwie bei der DB-Lösung ein besseres Gefühl...
Wie sind denn sonst so die Erfahrungen mit Sessions in Bezug auf Funktionssicherheit?
 
Zurück