feat(2.9.0): Hardwarekey an der WebUI-Anmeldung und die Roadmap als Tafel
Zwei angeforderte Funktionen, dazu zwei Befunde, die ihnen im Weg lagen. == Zweite Stufe der Anmeldung (#666) == Nach korrektem Passwort verlangt das WebUI einen registrierten FIDO2-Schluessel. Die Pflicht haengt je Konto daran, OB ein Schluessel registriert ist - nicht an einem Schalter. Ein Schalter, den man vor der Registrierung umlegt, sperrt den Betreiber aus seiner eigenen Verwaltung aus. Ohne Fremdbibliothek gebaut (das Projekt hat bewusst kein Composer): src/Core/Webauthn/ mit CBOR-Leser, COSE-nach-DER-Wandlung und Pruefung ueber openssl_verify (ES256, RS256). Auth::login() heisst jetzt Auth::startLogin() und liefert drei Zustaende statt eines bool - aus "wahr heisst drin" ist "wahr heisst vielleicht drin" geworden, und ein uebersehener Aufrufer soll an einem fehlenden Methodennamen scheitern statt an einer truthy Zeichenkette. Die Setup-API der Zielsysteme geht ueber verifyCredentials() daran vorbei und bleibt unberuehrt. Notfalltoken als Ersatzweg: nur als Hash in der Datenbank, Klartext einmalig angezeigt, Einmalgebrauch. Laeuft als gewoehnliches Formular ohne JavaScript - ein Ersatzweg mit denselben Voraussetzungen wie der Hauptweg waere keiner. Fehlversuche auf der zweiten Stufe laufen in dieselbe Konto- und IP-Sperre wie das Passwort; zuvor zaehlte nur die erste Stufe. Benutzerverwaltung kann Konten umbenennen. Beim Loeschen eines Kontos und beim Zuruecksetzen der Testinstanz werden seine Schluessel mitgenommen: credential_id ist geraeteweit eindeutig, eine verwaiste Zeile haette denselben Yubikey dauerhaft unbrauchbar gemacht. == Roadmap-Tafel (#664) == Neuer Unterreiter Roadmap -> Tafel. Der Server liefert die fertige Gliederung (RoadmapBoard, ?action=board), die Seite rendert nur - sonst schneidet das WebUI anders als die API und ein spaeterer Export ein drittes Mal anders. Phasen stehen nicht im Code: gepflegt in der neuen Tabelle roadmap_phases, sonst an den groessten Luecken der Rangfolge geschnitten und sichtbar als geraten gekennzeichnet. Links die gekappte Spalte des Erledigten, rechts "Wird ueberlegt". Nachpruefung ist ein eigener Zustand und wird nicht als erledigt gezeichnet. Klick oeffnet ein Seitenpanel: Volltext, Verlauf, Kommentieren mit action_taken, Aendern von Bereich, Rang, Zielfassung, Status und Ausloeser - ohne Seitenwechsel. Das war der eigentliche Gewinn: Wer etwas bemerkte, musste sich bisher die Nummer merken und im Bugtracker danach suchen. Tiefe Verweise #roadmap/<projekt>/item/<id>. Ziehen zwischen Phasen ueber ?action=rank_between. Fremdtext wird ausschliesslich ueber textContent gesetzt, nie ueber innerHTML. == Befund: roadmap_rank war projektweit und bereichslokal zugleich (#177) == Entschieden: projektweit. move() sucht den Nachbarn ohne Bereichsfilter, reorder() schreibt keinen ganzen Bereich mehr auf 10/20/30 um, sondern vertauscht nur die Raenge, die die Eintraege ohnehin halten. rank_between rechnet die Mitte zwischen zwei Nachbarn und nummeriert nur dann einmal neu - unter Erhalt der Reihenfolge -, wenn dort kein Platz mehr ist; die Antwort sagt es dann. == Befund: CSRF an sitzungsauthentifizierten API-Schreibzugriffen == ApiAuth akzeptiert die WebUI-Sitzung, die Endpunkte prueften dabei kein CSRF-Token. Der Kommentar in Http::beginJson - "ausschliesslich per Token-Header, niemals per Cookie" - traf auf die API nie zu. Schreibende Anfragen im Sitzungsweg verlangen jetzt csrf_token bzw. X-CSRF-Token. Token-Clients sind nicht betroffen und wurden gegengeprueft. == Geprueft == tests/e2e/suite_tafel.py neu (16 Faelle), Suite gesamt 172 von 172 gegen die Testinstanz. Die WebAuthn-Zeremonien zusaetzlich gegen einen Software- Authenticator mit echtem P-256 und echtem CBOR: Registrieren, Anmelden, verfaelschte Signatur, veraltete Herausforderung und Zaehler-Rueckfall als Klonverdacht werden je mit 403 abgewiesen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
edea46e342
commit
f0ed67ad29
@@ -355,46 +355,243 @@ final class Roadmap
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Vergibt die Reihenfolge eines Bereichs neu, in Zehnerschritten.
|
||||
* Ordnet die Eintraege eines Bereichs untereinander neu.
|
||||
*
|
||||
* Die Luecken sind der Zweck: Ein Eintrag laesst sich spaeter
|
||||
* dazwischenschieben (Rang 15), ohne dass alle folgenden neu geschrieben
|
||||
* werden muessen. Erst wenn die Luecke aufgebraucht ist, laeuft das hier
|
||||
* noch einmal.
|
||||
* WICHTIG - das war einmal anders, siehe #177: roadmap_rank gilt
|
||||
* PROJEKTWEIT, nicht innerhalb eines Bereichs. nextUp() sortiert danach
|
||||
* ohne jede Gruppierung nach epic, und die Tafel schneidet ihre Phasen
|
||||
* daraus. Die frueher hier vergebene Folge 10/20/30 je Bereich hatte
|
||||
* deshalb eine Wirkung, die niemand angefordert hatte: Eine Geste
|
||||
* innerhalb eines Bereichs schob dessen Eintraege gegenueber allen
|
||||
* anderen Bereichen durch die halbe Warteschlange.
|
||||
*
|
||||
* Jetzt werden ausschliesslich die Raenge vertauscht, die diese
|
||||
* Eintraege ohnehin schon halten. Ihre Plaetze in der projektweiten
|
||||
* Reihenfolge bleiben als Menge unveraendert - nur wer auf welchem
|
||||
* sitzt, aendert sich. Genau das ist die Geste, die gemeint war.
|
||||
*
|
||||
* @param list<int> $orderedIds Item-IDs in der gewuenschten Reihenfolge
|
||||
*/
|
||||
public function reorder(string $projectSlug, string $epic, array $orderedIds): int
|
||||
{
|
||||
$orderedIds = array_values(array_map('intval', $orderedIds));
|
||||
|
||||
if ($orderedIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($orderedIds), '?'));
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
UPDATE bugtracker_items
|
||||
SET roadmap_rank = :rank
|
||||
WHERE id = :id AND project_slug = :slug AND epic = :epic
|
||||
SELECT id, roadmap_rank FROM bugtracker_items
|
||||
WHERE project_slug = ? AND epic = ? AND id IN (' . $placeholders . ')
|
||||
');
|
||||
$stmt->execute(array_merge([$projectSlug, $epic], $orderedIds));
|
||||
|
||||
$known = [];
|
||||
$ranks = [];
|
||||
foreach ($stmt->fetchAll() ?: [] as $row) {
|
||||
$known[(int)$row['id']] = true;
|
||||
if ($row['roadmap_rank'] !== null) {
|
||||
$ranks[] = (int)$row['roadmap_rank'];
|
||||
}
|
||||
}
|
||||
|
||||
// Nur die Eintraege, die es wirklich gibt - und in der gewuenschten
|
||||
// Reihenfolge, nicht in der der Datenbank.
|
||||
$targets = array_values(array_filter($orderedIds, static fn ($id) => isset($known[$id])));
|
||||
|
||||
if ($targets === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
sort($ranks);
|
||||
|
||||
// Ungereihte Eintraege im Bereich brauchen zusaetzliche Plaetze.
|
||||
// Sie kommen hinter den hoechsten vorhandenen Rang, in Zehnerschritten -
|
||||
// dort standen sie ohnehin schon, naemlich am Ende.
|
||||
$next = ($ranks === [] ? 0 : $ranks[count($ranks) - 1]);
|
||||
while (count($ranks) < count($targets)) {
|
||||
$next += 10;
|
||||
$ranks[] = $next;
|
||||
}
|
||||
|
||||
$update = $this->db->prepare('
|
||||
UPDATE bugtracker_items SET roadmap_rank = :rank WHERE id = :id AND project_slug = :slug
|
||||
');
|
||||
|
||||
$touched = 0;
|
||||
$rank = 10;
|
||||
|
||||
foreach ($orderedIds as $id) {
|
||||
$stmt->execute([
|
||||
':rank' => $rank,
|
||||
':id' => (int)$id,
|
||||
':slug' => $projectSlug,
|
||||
':epic' => $epic,
|
||||
]);
|
||||
$touched += $stmt->rowCount();
|
||||
$rank += 10;
|
||||
foreach ($targets as $i => $id) {
|
||||
$update->execute([':rank' => $ranks[$i], ':id' => $id, ':slug' => $projectSlug]);
|
||||
$touched += $update->rowCount();
|
||||
}
|
||||
|
||||
return $touched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schiebt einen Eintrag innerhalb seines Bereichs um eine Position.
|
||||
* Setzt einen Eintrag zwischen zwei Nachbarn - das Ziehen auf der Tafel.
|
||||
*
|
||||
* Der Rang ist die Mitte zwischen den Nachbarraengen. Ist dort kein Platz
|
||||
* mehr, wird das Projekt EINMAL neu durchnummeriert (Zehnerschritte) und
|
||||
* danach erneut die Mitte genommen. Dass das passiert ist, steht im
|
||||
* Rueckgabewert - eine stille Neuvergabe aller Raenge waere das, wovor
|
||||
* #177 warnt.
|
||||
*
|
||||
* @param int|null $prevId Nachbar davor (kleinerer Rang), null = ganz nach vorn
|
||||
* @param int|null $nextId Nachbar dahinter (groesserer Rang), null = ganz nach hinten
|
||||
* @param int|null $minRank Untere Grenze der Zielspalte, falls sie leer ist
|
||||
* @param int|null $maxRank Obere Grenze der Zielspalte, falls sie leer ist
|
||||
* @return array{rank:int,renumbered:bool}
|
||||
*/
|
||||
public function rankBetween(
|
||||
int $itemId,
|
||||
?int $prevId,
|
||||
?int $nextId,
|
||||
?int $minRank = null,
|
||||
?int $maxRank = null
|
||||
): array {
|
||||
$stmt = $this->db->prepare('SELECT id, project_slug FROM bugtracker_items WHERE id = :id LIMIT 1');
|
||||
$stmt->execute([':id' => $itemId]);
|
||||
$item = $stmt->fetch();
|
||||
|
||||
if (!is_array($item)) {
|
||||
throw new \RuntimeException(sprintf('Item #%d existiert nicht.', $itemId));
|
||||
}
|
||||
|
||||
$projectSlug = (string)$item['project_slug'];
|
||||
|
||||
$bounds = $this->bounds($projectSlug, $itemId, $prevId, $nextId, $minRank, $maxRank);
|
||||
$rank = self::midpoint($bounds['lower'], $bounds['upper']);
|
||||
|
||||
if ($rank !== null) {
|
||||
$this->setRank($itemId, $rank);
|
||||
return ['rank' => $rank, 'renumbered' => false];
|
||||
}
|
||||
|
||||
// Kein Platz zwischen den Nachbarn. Einmal Luft schaffen.
|
||||
$this->renumber($projectSlug);
|
||||
|
||||
$bounds = $this->bounds($projectSlug, $itemId, $prevId, $nextId, $minRank, $maxRank);
|
||||
$rank = self::midpoint($bounds['lower'], $bounds['upper']);
|
||||
|
||||
if ($rank === null) {
|
||||
throw new \RuntimeException('Zwischen den Nachbarn ist auch nach dem Neunummerieren kein Platz.');
|
||||
}
|
||||
|
||||
$this->setRank($itemId, $rank);
|
||||
|
||||
return ['rank' => $rank, 'renumbered' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Raenge der beiden Nachbarn.
|
||||
*
|
||||
* @return array{lower:?int,upper:?int}
|
||||
*/
|
||||
private function bounds(
|
||||
string $projectSlug,
|
||||
int $itemId,
|
||||
?int $prevId,
|
||||
?int $nextId,
|
||||
?int $minRank,
|
||||
?int $maxRank
|
||||
): array {
|
||||
$rankOf = function (?int $id) use ($projectSlug, $itemId): ?int {
|
||||
if ($id === null || $id === $itemId) {
|
||||
return null;
|
||||
}
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT roadmap_rank FROM bugtracker_items
|
||||
WHERE id = :id AND project_slug = :slug LIMIT 1
|
||||
');
|
||||
$stmt->execute([':id' => $id, ':slug' => $projectSlug]);
|
||||
$rank = $stmt->fetchColumn();
|
||||
|
||||
return ($rank === false || $rank === null) ? null : (int)$rank;
|
||||
};
|
||||
|
||||
$lower = $rankOf($prevId);
|
||||
$upper = $rankOf($nextId);
|
||||
|
||||
// Leere Zielspalte: Dann geben die Phasengrenzen den Rahmen vor.
|
||||
if ($lower === null && $minRank !== null) {
|
||||
$lower = $minRank - 1;
|
||||
}
|
||||
if ($upper === null && $maxRank !== null) {
|
||||
$upper = $maxRank + 1;
|
||||
}
|
||||
|
||||
return ['lower' => $lower, 'upper' => $upper];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Mitte zwischen zwei Raengen, oder null wenn dort kein Platz ist.
|
||||
*/
|
||||
private static function midpoint(?int $lower, ?int $upper): ?int
|
||||
{
|
||||
if ($lower === null && $upper === null) {
|
||||
return 10;
|
||||
}
|
||||
|
||||
if ($lower === null) {
|
||||
// Ganz nach vorn. Unter 1 geht nichts mehr.
|
||||
return $upper > 1 ? (int)max(1, intdiv($upper, 2)) : null;
|
||||
}
|
||||
|
||||
if ($upper === null) {
|
||||
return $lower + 10;
|
||||
}
|
||||
|
||||
if ($upper - $lower < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return intdiv($lower + $upper, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergibt alle Raenge eines Projekts neu, in Zehnerschritten.
|
||||
*
|
||||
* Die bestehende Reihenfolge bleibt dabei erhalten - es wird nur Luft
|
||||
* zwischen die Eintraege gelassen. Laeuft ausschliesslich dann, wenn
|
||||
* zwischen zwei Nachbarn kein Platz mehr ist.
|
||||
*/
|
||||
public function renumber(string $projectSlug): int
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT id FROM bugtracker_items
|
||||
WHERE project_slug = :slug AND roadmap_rank IS NOT NULL
|
||||
ORDER BY roadmap_rank ASC, id ASC
|
||||
');
|
||||
$stmt->execute([':slug' => $projectSlug]);
|
||||
|
||||
$update = $this->db->prepare('UPDATE bugtracker_items SET roadmap_rank = :rank WHERE id = :id');
|
||||
|
||||
$rank = 0;
|
||||
$touched = 0;
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) ?: [] as $id) {
|
||||
$rank += 10;
|
||||
$update->execute([':rank' => $rank, ':id' => (int)$id]);
|
||||
$touched++;
|
||||
}
|
||||
|
||||
return $touched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schiebt einen Eintrag um eine Position in der Reihenfolge des Projekts.
|
||||
*
|
||||
* Tauscht die Raenge mit dem Nachbarn, statt sie durchzuzaehlen: Das ist
|
||||
* ein UPDATE auf zwei Zeilen und bleibt auch bei einem Bereich mit
|
||||
* hundert Eintraegen billig.
|
||||
* ein UPDATE auf zwei Zeilen und bleibt auch bei hundert Eintraegen
|
||||
* billig.
|
||||
*
|
||||
* Der Nachbar wird PROJEKTWEIT gesucht und nicht mehr nur im eigenen
|
||||
* Bereich (#177). Zuvor tauschte ein Klick die Raenge zweier Eintraege
|
||||
* desselben Bereichs, die in der projektweiten Reihenfolge weit
|
||||
* auseinanderlagen - der Eintrag sprang dann in der Warteschlange ueber
|
||||
* beliebig viele fremde Vorhaben hinweg, obwohl die Geste "eine Position"
|
||||
* hiess.
|
||||
*/
|
||||
public function move(int $itemId, string $direction): bool
|
||||
{
|
||||
@@ -415,9 +612,9 @@ final class Roadmap
|
||||
if ($item['roadmap_rank'] === null) {
|
||||
$max = $this->db->prepare('
|
||||
SELECT COALESCE(MAX(roadmap_rank), 0) FROM bugtracker_items
|
||||
WHERE project_slug = :slug AND epic = :epic
|
||||
WHERE project_slug = :slug
|
||||
');
|
||||
$max->execute([':slug' => $item['project_slug'], ':epic' => $item['epic']]);
|
||||
$max->execute([':slug' => $item['project_slug']]);
|
||||
|
||||
$newRank = ((int)$max->fetchColumn()) + 10;
|
||||
$this->setRank($itemId, $newRank);
|
||||
@@ -429,7 +626,7 @@ final class Roadmap
|
||||
|
||||
$neighbour = $this->db->prepare('
|
||||
SELECT id, roadmap_rank FROM bugtracker_items
|
||||
WHERE project_slug = :slug AND epic = :epic
|
||||
WHERE project_slug = :slug
|
||||
AND roadmap_rank IS NOT NULL
|
||||
AND roadmap_rank ' . $comparison . ' :rank
|
||||
ORDER BY roadmap_rank ' . $sort . ', id ' . $sort . '
|
||||
@@ -437,7 +634,6 @@ final class Roadmap
|
||||
');
|
||||
$neighbour->execute([
|
||||
':slug' => $item['project_slug'],
|
||||
':epic' => $item['epic'],
|
||||
':rank' => (int)$item['roadmap_rank'],
|
||||
]);
|
||||
$other = $neighbour->fetch();
|
||||
@@ -462,22 +658,60 @@ final class Roadmap
|
||||
// Bereiche verwalten
|
||||
// ==================================================================
|
||||
|
||||
public function saveEpic(string $projectSlug, string $epic, string $title, ?string $summary, int $sortOrder): void
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO roadmap_epics (project_slug, epic, title, summary, sort_order)
|
||||
VALUES (:slug, :epic, :title, :summary, :sort)
|
||||
ON DUPLICATE KEY UPDATE title = VALUES(title),
|
||||
summary = VALUES(summary),
|
||||
sort_order = VALUES(sort_order)
|
||||
');
|
||||
$stmt->execute([
|
||||
/**
|
||||
* Legt einen Themenbereich an oder aendert ihn.
|
||||
*
|
||||
* $color und $highlight steuern die Spur auf der Tafel. Das Kennzeichen
|
||||
* heisst absichtlich "hervorheben" und nicht "Sicherheit": Welcher
|
||||
* Bereich ins Auge fallen soll, ist eine Entscheidung des Projekts.
|
||||
* Bei uns ist es die Sicherheit, anderswo heisst er anders - ein Name im
|
||||
* Code waere fuer genau ein Projekt richtig.
|
||||
*
|
||||
* $color und $highlight auf null zu lassen aendert sie nicht. Sonst
|
||||
* wuerde jeder Aufruf aus dem bestehenden Formular, das diese Felder
|
||||
* nicht kennt, die Farbe stillschweigend loeschen.
|
||||
*/
|
||||
public function saveEpic(
|
||||
string $projectSlug,
|
||||
string $epic,
|
||||
string $title,
|
||||
?string $summary,
|
||||
int $sortOrder,
|
||||
?string $color = null,
|
||||
?bool $highlight = null
|
||||
): void {
|
||||
$columns = 'project_slug, epic, title, summary, sort_order';
|
||||
$values = ':slug, :epic, :title, :summary, :sort';
|
||||
$updates = 'title = VALUES(title), summary = VALUES(summary), sort_order = VALUES(sort_order)';
|
||||
|
||||
$params = [
|
||||
':slug' => $projectSlug,
|
||||
':epic' => mb_substr(trim($epic), 0, 24),
|
||||
':title' => mb_substr(trim($title), 0, 160),
|
||||
':summary' => ($summary !== null && trim($summary) !== '') ? trim($summary) : null,
|
||||
':sort' => $sortOrder,
|
||||
]);
|
||||
];
|
||||
|
||||
if ($color !== null) {
|
||||
$columns .= ', color';
|
||||
$values .= ', :color';
|
||||
$updates .= ', color = VALUES(color)';
|
||||
$params[':color'] = trim($color) !== '' ? mb_substr(trim($color), 0, 16) : null;
|
||||
}
|
||||
|
||||
if ($highlight !== null) {
|
||||
$columns .= ', highlight';
|
||||
$values .= ', :highlight';
|
||||
$updates .= ', highlight = VALUES(highlight)';
|
||||
$params[':highlight'] = $highlight ? 1 : 0;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO roadmap_epics (' . $columns . ')
|
||||
VALUES (' . $values . ')
|
||||
ON DUPLICATE KEY UPDATE ' . $updates . '
|
||||
');
|
||||
$stmt->execute($params);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user