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:
@@ -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",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user