feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)

- Reminder-Tabelle mit Zeilenschutz (Mandant+Benutzer, Systemlesen fuer den E-Mail-Planer), API reminders (Liste, Anlegen)
- Kachel "Erinnerungen", globaler Melder im Portalrahmen (Browser und Desktop, je Faelligkeit einmal)
- Desktop: Laufzeit-Berechtigung fuer Benachrichtigungen nur fuer die gespeicherte Server-Adresse
- Zugriffsklassifikation nachgemessen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 13:54:07 +02:00
parent cd1f8f6cda
commit 325c5ddbf2
31 changed files with 1969 additions and 11 deletions
+246
View File
@@ -324,6 +324,133 @@ fn server_host(url: Option<&str>) -> Option<String> {
}
}
/// Die drei Benachrichtigungs-Berechtigungen, die die Server-Seite in der
/// Webview bekommt -- genau diese und keine weitere (T-IF2-03). Die
/// Bezeichner stammen aus tauri-plugin-notification 2.3.3
/// (`permissions/autogenerated/commands/notify.toml`,
/// `is_permission_granted.toml`, `request_permission.toml`). Eine unbekannte
/// Kennung wuerde in `add_capability` ebenfalls in einen Panic laufen; darum
/// pinnt ein Test die exakte Menge samt Reihenfolge.
const SERVER_NOTIFICATION_PERMISSIONS: [&str; 3] = [
"notification:allow-notify",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
];
/// Bereits berechtigte Herkunfts-Muster. `add_capability` haengt bei jedem
/// Aufruf an die bestehenden Erlaubnisse an; ein zweites Speichern derselben
/// Adresse soll nichts verdoppeln.
static GRANTED_SERVER_ORIGINS: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// Baut aus der gespeicherten Server-Adresse das URL-Muster fuer die
/// Laufzeit-Berechtigung: genau `Schema://Host[:Port]`, ohne Pfad, Query und
/// Fragment (quick-260929-if2, E-01).
///
/// Jedes Zeichen des Hosts ausserhalb von ASCII `A-Z a-z 0-9 . -` bekommt ein
/// Backslash (URLPattern-Maskierung). Grund: der `url`-Crate nimmt
/// `http://*.example.com/` an und liefert den Host `*.example.com`, der
/// ungeschuetzt zum Platzhalter fuer JEDE Subdomain wuerde. IPv6-Adressen
/// kommen in Klammern (`[::1]`); der URLPattern-Zerleger (urlpattern 0.3.0)
/// weist `http://[::1]:8080` mit `Tokenizer(InvalidName, 1)` ab, die
/// maskierte Form `http://\[\:\:1\]:8080` dagegen parst und trifft nur
/// `[::1]:8080` (beides gemessen).
///
/// Der Port steht nur, wenn er ausdruecklich und nicht der Standardport ist
/// (der `url`-Crate laesst Standardports weg).
///
/// SELBSTPRUEFUNG, Pflicht: `add_capability` fuehrt intern
/// `Resolved::resolve(..).unwrap()` aus, waehrend es die Sperre der
/// Laufzeit-Berechtigungen haelt, und tauri-utils 2.9.3 gerät bei einem
/// nicht zerlegbaren Muster in einen Panic ("invalid URL pattern for remote
/// URL"). Ein Fehler kommt NICHT als `Err` zurueck, er legt `setup()` lahm --
/// und ein `catch_unwind` haette die Sperre vergiftet, sodass jeder spaetere
/// IPC-Aufruf scheitert. Deshalb wird das Muster VOR dem Aufruf mit
/// demselben Zerleger geprueft, den Tauri fuer `remote.urls` benutzt
/// (`RemoteUrlPattern`), und muss die gespeicherte Adresse tatsaechlich
/// treffen. Sonst `None`: kein Muster, keine Berechtigung.
fn server_origin_pattern(url: &str) -> Option<String> {
use std::str::FromStr;
let parsed = parse_server_url(url).ok()?;
let host = parsed.host_str()?;
let escaped: String = host
.chars()
.flat_map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
vec![c]
} else {
vec!['\\', c]
}
})
.collect();
let mut pattern = format!("{}://{}", parsed.scheme(), escaped);
if let Some(port) = parsed.port() {
pattern.push_str(&format!(":{}", port));
}
let checked = tauri::utils::acl::RemoteUrlPattern::from_str(&pattern).ok()?;
if checked.test(&parsed) {
Some(pattern)
} else {
None
}
}
/// Erteilt der gespeicherten Server-Adresse -- und nur ihr -- zur Laufzeit das
/// Recht, das Benachrichtigungs-Plugin aus der Webview aufzurufen
/// (quick-260929-if2, E-01). Der globale Erinnerungs-Melder der Web-Oberflaeche
/// ruft `plugin:notification|notify`, damit auch bei ausgeblendetem Fenster
/// (Infobereich) eine Windows-Meldung erscheint.
///
/// WARUM ZUR LAUFZEIT UND NICHT IN `capabilities/default.json`: die statische
/// Datei hat absichtlich keinen `remote`-Block (T-JN2-01, siehe
/// `get_server_url`), Tauri verweigert deshalb jeden Plugin- und App-Aufruf
/// aus der Server-Seite. Die Serveradresse ist erst zur Laufzeit bekannt und
/// je Installation verschieden. Die Berechtigung gilt genau fuer den
/// gespeicherten Ursprung (maskiert und selbstgeprueft, siehe
/// `server_origin_pattern` -- nie ein Platzhalter), nur fuer das Fenster
/// `main` und nur fuer die drei Benachrichtigungs-Rechte. App-Befehle wie
/// `save_server_url` bleiben lokal-only, T-JN2-01 gilt unveraendert. Nach
/// einem Serverwechsel behaelt der alte Ursprung sein Benachrichtigungsrecht
/// bis zum App-Neustart (akzeptiert, T-IF2-03).
///
/// Verworfene Alternativen: (1) ein Abruf der API von Rust aus -- der
/// vorgeschaltete Basic-Auth-Schutz von alpha liefert reqwest ein 401 (wie
/// beim Updater), und das Sitzungs-Cookie lebt nur in der Webview.
/// (2) Die Web-Benachrichtigung in der Webview -- das Plugin ersetzt
/// `window.Notification` durch einen Ersatz mit demselben IPC-Aufruf, der
/// unter Windows bei jedem Seitenaufruf "denied" meldet.
///
/// Darf den Start nie verhindern: bei einem Muster, das sich nicht bilden
/// laesst, bleibt es bei einer Zeile auf stderr -- die Desktop-Meldung ist
/// dann fuer diese Adresse aus, Browser-Meldung und E-Mail laufen weiter.
fn grant_server_notifications(app: &AppHandle, url: &str) {
let Some(pattern) = server_origin_pattern(url) else {
eprintln!(
"Benachrichtigungen: keine Berechtigung fuer die Server-Adresse erteilt (Muster nicht bildbar)"
);
return;
};
{
let mut granted = match GRANTED_SERVER_ORIGINS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if granted.contains(&pattern) {
return;
}
granted.push(pattern.clone());
}
let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications")
.remote(pattern)
.local(false)
.window("main");
for permission in SERVER_NOTIFICATION_PERMISSIONS {
capability = capability.permission(permission);
}
if let Err(e) = app.add_capability(capability) {
eprintln!("Benachrichtigungen: Berechtigung nicht erteilt: {}", e);
}
}
/// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide
/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in
/// `update_labels`.
@@ -666,6 +793,8 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), String> {
store.set("server_url", serde_json::json!(normalized));
store.save().map_err(|e| e.to_string())?;
// Vor dem Navigieren: die Seite darf danach sofort Desktop-Meldungen ausloesen.
grant_server_notifications(&app, &normalized);
apply_server(&app, Some(&normalized));
spawn_version_check(app.clone(), normalized.clone(), false);
@@ -801,6 +930,9 @@ pub fn run() {
let server_url = stored_server_url(app.handle());
if let Some(url) = &server_url {
// Vor dem ersten `navigate`: Benachrichtigungsrecht fuer die
// gespeicherte Adresse (quick-260929-if2, E-01).
grant_server_notifications(app.handle(), url);
if let Some(window) = app.get_webview_window("main") {
if let Ok(parsed) = parse_server_url(url) {
let _ = window.navigate(with_desktop_marker(&parsed));
@@ -1361,4 +1493,118 @@ mod tests {
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
}
// --- server_origin_* (quick-260929-if2, E-01) ---
fn muster(p: &str) -> tauri::utils::acl::RemoteUrlPattern {
p.parse().expect("Muster muss zerlegbar sein")
}
fn adresse(s: &str) -> tauri::Url {
tauri::Url::parse(s).expect("Testadresse muss gueltig sein")
}
#[test]
fn server_origin_entfernt_pfad_query_und_fragment() {
assert_eq!(
server_origin_pattern("https://alpha.tessera.ctl.de/dashboard?x=1#h").as_deref(),
Some("https://alpha.tessera.ctl.de")
);
}
#[test]
fn server_origin_behaelt_nicht_standard_port() {
assert_eq!(
server_origin_pattern("http://192.168.13.12:8080/").as_deref(),
Some("http://192.168.13.12:8080")
);
}
#[test]
fn server_origin_laesst_standardport_weg() {
assert_eq!(
server_origin_pattern("https://alpha.tessera.ctl.de:443/").as_deref(),
Some("https://alpha.tessera.ctl.de")
);
}
#[test]
fn server_origin_mit_und_ohne_schlussstrich_gleich() {
assert_eq!(
server_origin_pattern("https://tessera.example.de/"),
server_origin_pattern("https://tessera.example.de")
);
assert!(server_origin_pattern("https://tessera.example.de").is_some());
}
#[test]
fn server_origin_none_bei_fremdem_schema_und_unparsbarer_eingabe() {
assert_eq!(server_origin_pattern("ftp://tessera.example.de/"), None);
assert_eq!(server_origin_pattern("das ist keine adresse"), None);
assert_eq!(server_origin_pattern(""), None);
}
#[test]
fn server_origin_ipv6_wird_maskiert() {
assert_eq!(
server_origin_pattern("http://[::1]:8080/").as_deref(),
Some(r"http://\[\:\:1\]:8080")
);
}
#[test]
fn server_origin_platzhalter_host_wird_maskiert() {
assert_eq!(
server_origin_pattern("http://*.example.com/").as_deref(),
Some(r"http://\*.example.com")
);
}
#[test]
fn server_origin_trifft_eigenen_ursprung_mit_anderem_pfad_und_query() {
let p = muster(&server_origin_pattern("https://alpha.tessera.ctl.de/x").unwrap());
assert!(p.test(&adresse("https://alpha.tessera.ctl.de/")));
assert!(p.test(&adresse("https://alpha.tessera.ctl.de/dashboard?a=b#c")));
}
#[test]
fn server_origin_trifft_kein_anderes_schema_port_subdomain_oder_host() {
let p = muster(&server_origin_pattern("https://alpha.tessera.ctl.de/").unwrap());
assert!(!p.test(&adresse("http://alpha.tessera.ctl.de/")));
assert!(!p.test(&adresse("https://alpha.tessera.ctl.de:8443/")));
assert!(!p.test(&adresse("https://x.alpha.tessera.ctl.de/")));
assert!(!p.test(&adresse("https://beta.tessera.ctl.de/")));
let mit_port = muster(&server_origin_pattern("http://192.168.13.12:8080/").unwrap());
assert!(mit_port.test(&adresse("http://192.168.13.12:8080/a")));
assert!(!mit_port.test(&adresse("http://192.168.13.12:8081/")));
assert!(!mit_port.test(&adresse("http://192.168.13.12/")));
}
#[test]
fn server_origin_platzhalter_host_trifft_keine_subdomain() {
let p = muster(&server_origin_pattern("http://*.example.com/").unwrap());
assert!(!p.test(&adresse("http://a.example.com/")));
}
#[test]
fn server_origin_ipv6_trifft_nur_die_eigene_adresse() {
let p = muster(&server_origin_pattern("http://[::1]:8080/").unwrap());
assert!(p.test(&adresse("http://[::1]:8080/dashboard")));
assert!(!p.test(&adresse("http://[::2]:8080/")));
assert!(!p.test(&adresse("http://[::1]/")));
}
#[test]
fn server_origin_berechtigungen_sind_genau_diese_drei() {
// Pinnt die Menge (T-IF2-03): ein zusaetzliches `notification:default`
// oder eine Platzhalter-Berechtigung laesst den Test rot werden.
assert_eq!(
SERVER_NOTIFICATION_PERMISSIONS,
[
"notification:allow-notify",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
]
);
}
}