feat(frontend): gemeinsam am Server-Dokument arbeiten (?live=, Schritt 6)
Der Editor fuehrt ein Dokument des Backends: laden, nach 1,5 s Ruhe das Diff schicken, ueber einen offenen Abruf fremde Aenderungen einspielen — ohne Neuladen, mit mitwandernder Schreibmarke. Dazu CORS im Backend; ohne das blockiert der Browser jeden Aufruf. Zwei Fehler hat erst der Live-Test gegen das laufende Backend gefunden, beide an der Naht zwischen Modul und Verdrahtung (D54-Nachtrag 3): Der Konflikt entstand nie. Mit laufendem Feed zieht die Schattenkopie staendig nach, die eigene Basis ist also nie veraltet — der Server haette nie 409 geantwortet, und die fremde Zeile waere stillschweigend ueberschrieben worden. Der Client prueft die Ueberschneidung jetzt selbst gegen den ungesendeten Text; den kennt der Server nicht. Die Nummer begann nach jedem Neuladen wieder bei 1, waehrend die Kennung blieb — der Server hielt die erste echte Aenderung fuer eine Wiederholung und tat nichts. Beides liegt jetzt im sessionStorage: je Tab, ueberlebt Neuladen. Je Tab ist zugleich die richtige Aussage, zwei Tabs sind zwei Schreiber. Nachgemessen im Browser: fremde Aenderung erscheint ohne Neuladen, Getipptes erreicht den Server, beide Konflikt-Knoepfe tun was sie sagen, und die Schreibmarke steht nach zwei fremd eingefuegten Zeilen darueber unveraendert bei Zeile+2, Spalte 8. 518 Frontend-Tests, 135 im Backend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5881be1a2c
commit
08965157a1
@@ -1,9 +1,10 @@
|
||||
# Live-Editing über HTTP (Variante „Simpel")
|
||||
|
||||
Status: **Konzept entschieden** (D76), **Schritte 1–5 der Umsetzungsreihenfolge
|
||||
gebaut** (Zeilen-Diff, zweistufige Historie, `PATCH /content`, Änderungsfeed,
|
||||
Master-Passwort); der Client steht aus, ebenso das Umbenennen per
|
||||
`PATCH /title` und damit das Ereignis `RENAMED`. Die offenen
|
||||
Status: **umgesetzt** — alle sechs Schritte der Umsetzungsreihenfolge stehen
|
||||
(Zeilen-Diff, zweistufige Historie, `PATCH /content`, Änderungsfeed,
|
||||
Master-Passwort, Client). Offen bleiben das Umbenennen per `PATCH /title`
|
||||
(und damit das Ereignis `RENAMED`), ein Eingabefeld für den Anzeigenamen und
|
||||
die Präsenz-Anzeige. Die offenen
|
||||
Punkte des ersten Entwurfs sind beantwortet; die Begründungen stehen in
|
||||
`docs/DECISIONS.md` unter D76 und werden hier nicht wiederholt, sondern nur
|
||||
verwiesen. Was beim Bauen zusätzlich zu entscheiden war, steht dort in
|
||||
@@ -24,7 +25,9 @@ Nachtrag 4.
|
||||
deshalb ausschließlich auf **physischen Zeilen** und braucht keine IDs.
|
||||
- Bei echtem Gleichzeitig-Konflikt: Update ablehnen, **der Client
|
||||
entscheidet** (rebase, neu laden, verwerfen). Das Dokument darf nie
|
||||
kaputtgehen.
|
||||
kaputtgehen. **Der Client erkennt den Konflikt zusätzlich selbst**, wenn eine
|
||||
fremde Änderung die Zeilen trifft, an denen gerade getippt wird — der Server
|
||||
kann das nicht sehen, er kennt den ungesendeten Text nicht (D76-Nachtrag 7).
|
||||
|
||||
### Zugriff und Identität
|
||||
|
||||
@@ -433,7 +436,8 @@ verworfen.
|
||||
4. ~~`GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen
|
||||
(Spec + Cucumber)~~ — gebaut, `ChangeNotifier` + `LiveEditingService`
|
||||
5. ~~Master-Passwort für `GET /documents` (Spring Security)~~ — gebaut
|
||||
6. Client-Anpassung (Feed-Schleife, lokales Anwenden, Konfliktdialog)
|
||||
6. ~~Client-Anpassung (Feed-Schleife, lokales Anwenden, Konfliktdialog)~~ —
|
||||
gebaut, `frontend/src/live.js` + `app.js`; Befunde in D76-Nachtrag 7
|
||||
|
||||
**Vor Schritt 4** steht die Vermessung der Zielumgebung (siehe „Betrieb") —
|
||||
sie bestimmt den `wait`-Wert und im Extremfall, ob Long Polling dort
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.werkbaum.api
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.cors.CorsConfiguration
|
||||
import org.springframework.web.cors.CorsConfigurationSource
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
|
||||
|
||||
/**
|
||||
* Von welchen Herkünften darf der Editor die API ansprechen?
|
||||
*
|
||||
* Voreinstellung `*`. Das ist hier keine Nachlässigkeit, sondern das
|
||||
* Zugriffsmodell (D76): Wer die unerratbare UUID hat, darf; wer sie nicht hat,
|
||||
* findet sie auch über eine erlaubte Herkunft nicht. CORS schützt Cookies und
|
||||
* mitgesendete Anmeldedaten — beides gibt es hier nicht (`credentials: omit`).
|
||||
* Der Editor wiederum läuft je nach Installation überall: GitHub Pages, eigene
|
||||
* Domain, Dev-Server, `file://`.
|
||||
*
|
||||
* Wer es enger will, setzt `werkbaum.cors.allowed-origins` auf die eigenen
|
||||
* Adressen.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "werkbaum.cors")
|
||||
data class CorsProperties(
|
||||
val allowedOrigins: List<String> = listOf("*"),
|
||||
)
|
||||
|
||||
@Configuration
|
||||
class CorsConfiguration(private val properties: CorsProperties) {
|
||||
|
||||
@Bean
|
||||
fun corsConfigurationSource(): CorsConfigurationSource {
|
||||
val config = CorsConfiguration().apply {
|
||||
allowedOriginPatterns = properties.allowedOrigins
|
||||
allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
// Authorization fuer das Master-Passwort, Content-Type fuer JSON.
|
||||
allowedHeaders = listOf("Authorization", "Content-Type")
|
||||
// Nichts Vertrauliches im Spiel; Cookies werden nie mitgesendet.
|
||||
allowCredentials = false
|
||||
maxAge = 3600
|
||||
}
|
||||
return UrlBasedCorsConfigurationSource().apply {
|
||||
registerCorsConfiguration("/api/**", config)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,10 @@ class SecurityConfiguration {
|
||||
properties: MasterPasswordProperties,
|
||||
): SecurityFilterChain =
|
||||
http
|
||||
// CORS-Regeln gelten auch fuer die Preflight-Anfrage - die traegt
|
||||
// keine Anmeldedaten und darf deshalb nicht an der Kette haengen
|
||||
// bleiben.
|
||||
.cors { }
|
||||
// Zustandslose API: keine Sitzung, kein CSRF-Token. Der Schutz
|
||||
// hängt am Passwort, nicht an einem Cookie – ein CSRF-Token
|
||||
// schützte hier nichts und bräche jeden Client.
|
||||
|
||||
@@ -37,6 +37,12 @@ werkbaum:
|
||||
# haelt einen Long-Poll 30 s durch, seine Zeitgrenzen liegen bei 300 s.
|
||||
max-wait: 25s
|
||||
|
||||
# Von welchen Herkuenften darf der Editor die API ansprechen? "*" ist die
|
||||
# Voreinstellung: Zugriff regelt die unerratbare UUID, nicht die Herkunft,
|
||||
# und Cookies werden nie mitgesendet. Enger geht ueber eine Liste.
|
||||
cors:
|
||||
allowed-origins: "*"
|
||||
|
||||
# Schutz der Dokumentenliste. BCrypt-Hash, NIE im Repository - er kommt aus
|
||||
# der Umgebung. Ohne ihn bleibt GET /documents gesperrt.
|
||||
master-password:
|
||||
|
||||
Reference in New Issue
Block a user