diff --git a/.github/workflows/update-webpki-roots.yml b/.github/workflows/update-webpki-roots.yml new file mode 100644 index 000000000..e1efdb0d6 --- /dev/null +++ b/.github/workflows/update-webpki-roots.yml @@ -0,0 +1,71 @@ +name: Update webpki-roots + +# Weekly refresh of the compiled-in TLS root certificates (the webpki-roots +# crate, a snapshot of the Mozilla root store). Roots are otherwise frozen at +# whatever Cargo.lock pins, so old builds miss newly added CAs and keep +# removed (distrusted) ones. Changes go through a PR on purpose: added or +# removed roots should be reviewed, not silently baked into releases. +# +# Note: PRs created with the default GITHUB_TOKEN do not trigger other +# workflows (GitHub limitation). Close and reopen the PR, or push to its +# branch, to run CI on it. + +on: + schedule: + - cron: "0 3 * * 1" + workflow_dispatch: + +# A manual dispatch overlapping the weekly run would race it force-pushing +# the same branch; queue instead of overlapping, and never cancel a run +# that may have already pushed. +concurrency: + group: update-webpki-roots + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + env: + BRANCH: auto-update-webpki-roots + steps: + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Update webpki-roots in all lockfiles + id: update + run: | + set -e + git ls-files -z '*Cargo.lock' | while IFS= read -r -d '' lock; do + dir=$(dirname "$lock") + for v in $(sed -n '/name = "webpki-roots"/{n;s/.*version = "\(.*\)"/\1/p;}' "$lock" | sort -u); do + echo "updating webpki-roots@$v in $dir" + (cd "$dir" && cargo update -p "webpki-roots@$v") + done + done + if git diff --quiet -- '*Cargo.lock'; then + echo "changed=0" >> "$GITHUB_OUTPUT" + else + echo "changed=1" >> "$GITHUB_OUTPUT" + git --no-pager diff -- '*Cargo.lock' + fi + + - name: Create pull request + if: steps.update.outputs.changed == '1' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + git add -- '*Cargo.lock' + git commit -m "chore: update webpki-roots to latest Mozilla root store" + git push -f origin "$BRANCH" + if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then + gh pr create \ + --title "chore: update webpki-roots to latest Mozilla root store" \ + --body "Automated weekly refresh of the compiled-in TLS root certificates (webpki-roots). Please review the added/removed roots. CI does not run automatically on PRs created by GITHUB_TOKEN; close and reopen this PR to trigger it." + fi diff --git a/Cargo.lock b/Cargo.lock index 91543c30d..aa88bc7db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3821,7 +3821,7 @@ dependencies = [ "url", "users 0.11.0", "uuid", - "webpki-roots 1.0.4", + "webpki-roots 1.0.9", "webrtc", "whoami", "winapi 0.3.9", @@ -4020,7 +4020,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.4", + "webpki-roots 1.0.9", ] [[package]] @@ -7112,7 +7112,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.4", + "webpki-roots 1.0.9", ] [[package]] @@ -8826,7 +8826,7 @@ dependencies = [ "tokio-native-tls", "tokio-rustls", "tungstenite", - "webpki-roots 0.26.9", + "webpki-roots 0.26.11", ] [[package]] @@ -9140,7 +9140,7 @@ dependencies = [ "sha1", "thiserror 2.0.17", "utf-8", - "webpki-roots 0.26.9", + "webpki-roots 0.26.11", ] [[package]] @@ -9813,18 +9813,18 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.9" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29aad86cec885cafd03e8305fd727c418e970a521322c91688414d5b8efba16b" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "rustls-pki-types", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index cb3faf163..94c3c2a72 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -4048,7 +4048,8 @@ Widget netWorkErrorWidget() { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text(translate("network_error_tip")), + if (!gFFI.userModel.networkErrorFromServer.value) + Text(translate("network_error_tip")), ElevatedButton( onPressed: gFFI.userModel.refreshCurrentUser, child: Text(translate("Retry"))) diff --git a/flutter/lib/common/widgets/login.dart b/flutter/lib/common/widgets/login.dart index 319171895..fa64e0eb5 100644 --- a/flutter/lib/common/widgets/login.dart +++ b/flutter/lib/common/widgets/login.dart @@ -267,8 +267,7 @@ class _WidgetOPState extends State { Padding( padding: const EdgeInsets.only(top: 8.0), child: Builder(builder: (context) { - final errorColor = - Theme.of(context).colorScheme.error; + final errorColor = Theme.of(context).colorScheme.error; final bgColor = Theme.of(context) .colorScheme .errorContainer @@ -289,12 +288,11 @@ class _WidgetOPState extends State { Flexible( child: SelectableText( translate(_failedMsg), - style: DefaultTextStyle.of(context) - .style - .copyWith( - fontSize: 13, - color: errorColor, - ), + style: + DefaultTextStyle.of(context).style.copyWith( + fontSize: 13, + color: errorColor, + ), ), ), ], @@ -468,9 +466,22 @@ Future loginDialog() async { bool isCloseHovered = false; final loginOptions = [].obs; - Future.delayed(Duration.zero, () async { - loginOptions.value = await UserModel.queryOidcLoginOptions(); - }); + final loginOptionsError = Rxn(); + final loginOptionsInProgress = false.obs; + fetchLoginOptions() async { + loginOptionsInProgress.value = true; + try { + loginOptions.value = await UserModel.queryOidcLoginOptions(); + loginOptionsError.value = null; + } catch (e) { + debugPrint("queryOidcLoginOptions failed: $e"); + loginOptionsError.value = e.toString(); + } finally { + loginOptionsInProgress.value = false; + } + } + + Future.delayed(Duration.zero, fetchLoginOptions); final res = await gFFI.dialogManager.show((setState, close, context) { username.addListener(() { @@ -574,6 +585,36 @@ Future loginDialog() async { } thirdAuthWidget() => Obx(() { + final error = loginOptionsError.value; + final inProgress = loginOptionsInProgress.value; + if (error != null) { + return Column( + children: [ + const SizedBox(height: 8.0), + // NOT use Offstage to wrap LinearProgressIndicator + if (inProgress) const LinearProgressIndicator(), + if (!inProgress) + Text( + translate('network_error_tip'), + style: const TextStyle(fontSize: 12), + textAlign: TextAlign.center, + ), + TextButton( + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.primary, + ), + onPressed: inProgress ? null : fetchLoginOptions, + child: Text(translate('Retry')), + ), + if (!inProgress) + SelectableText( + error, + style: const TextStyle(fontSize: 11, color: Colors.red), + textAlign: TextAlign.center, + ), + ], + ); + } return Offstage( offstage: loginOptions.isEmpty, child: Column( diff --git a/flutter/lib/models/user_model.dart b/flutter/lib/models/user_model.dart index cecb58eaa..9ebb6f76b 100644 --- a/flutter/lib/models/user_model.dart +++ b/flutter/lib/models/user_model.dart @@ -20,6 +20,9 @@ class UserModel { final RxString avatar = ''.obs; final RxBool isAdmin = false.obs; final RxString networkError = ''.obs; + // True when networkError carries a server-reported error rather than a + // connectivity failure; netWorkErrorWidget hides the network tip then. + final RxBool networkErrorFromServer = false.obs; bool get isLogin => userName.isNotEmpty; String get displayNameOrUserName => displayName.value.trim().isEmpty ? userName.value : displayName.value; @@ -50,6 +53,7 @@ class UserModel { void refreshCurrentUser() async { if (bind.isDisableAccount()) return; networkError.value = ''; + networkErrorFromServer.value = false; final token = bind.mainGetLocalOption(key: 'access_token'); if (token == '') { await updateOtherModels(); @@ -85,6 +89,10 @@ class UserModel { final data = json.decode(decode_http_response(response)); final error = data['error']; if (error != null) { + // The only failure known to come from the server itself, so the + // check-your-network tip does not apply. Flag before the message is + // set in the catch below so rebuilds read a consistent pair. + networkErrorFromServer.value = true; throw error; } @@ -92,6 +100,13 @@ class UserModel { _parseAndUpdateUser(user); } catch (e) { debugPrint('Failed to refreshCurrentUser: $e'); + // Surface failures in the address book / group tabs, which offer a + // retry. Anything not flagged above -- transport errors, non-JSON or + // unexpected-schema bodies (e.g. a filter's block page) -- keeps the + // check-your-network tip. + if (networkError.value.isEmpty) { + networkError.value = e.toString(); + } } finally { refreshingUser = false; await updateOtherModels(); @@ -219,28 +234,31 @@ class UserModel { return loginResponse; } + /// Throws on network failure so callers can surface the error and offer a + /// retry; returns an empty list when the server has no third-party login. static Future> queryOidcLoginOptions() async { - try { - final url = await bind.mainGetApiServer(); - if (url.trim().isEmpty) return []; - final resp = await http.get(Uri.parse('$url/api/login-options')); - final List ops = []; - for (final item in jsonDecode(resp.body)) { - ops.add(item as String); - } - for (final item in ops) { - if (item.startsWith('common-oidc/')) { - return jsonDecode(item.substring('common-oidc/'.length)); - } - } - return ops - .where((item) => item.startsWith('oidc/')) - .map((item) => {'name': item.substring('oidc/'.length)}) - .toList(); - } catch (e) { - debugPrint( - "queryOidcLoginOptions: jsonDecode resp body failed: ${e.toString()}"); - return []; + final url = await bind.mainGetApiServer(); + if (url.trim().isEmpty) return []; + final resp = await http.get(Uri.parse('$url/api/login-options')); + const successStatusCodeStart = 200; + const successStatusCodeEnd = 300; + if (resp.statusCode < successStatusCodeStart || + resp.statusCode >= successStatusCodeEnd) { + throw RequestException( + resp.statusCode, resp.reasonPhrase ?? 'Request failed'); } + final List ops = []; + for (final item in jsonDecode(resp.body)) { + ops.add(item as String); + } + for (final item in ops) { + if (item.startsWith('common-oidc/')) { + return jsonDecode(item.substring('common-oidc/'.length)); + } + } + return ops + .where((item) => item.startsWith('oidc/')) + .map((item) => {'name': item.substring('oidc/'.length)}) + .toList(); } } diff --git a/flutter/lib/utils/http_service.dart b/flutter/lib/utils/http_service.dart index 1618e25ff..9c9624b56 100644 --- a/flutter/lib/utils/http_service.dart +++ b/flutter/lib/utils/http_service.dart @@ -44,32 +44,51 @@ class HttpService { return _parseHttpResponse(resJson); } + // Bounds only the pure-Dart branch below, which the OS would otherwise + // let hang forever (e.g. a black-holed TLS handshake), see #15700. + // The Rust branch has its own 12s-per-attempt timeouts and must be + // awaited to completion: a Dart-side timeout there would race the + // URL-keyed ASYNC_HTTP_STATUS entry of the abandoned request. + static const _requestTimeout = Duration(seconds: 30); + Future _pollFlutterHttp( Uri url, HttpMethod method, { Map? headers, dynamic body, }) async { - var response = http.Response('', 400); + final client = http.Client(); + try { + var response = http.Response('', 400); - switch (method) { - case HttpMethod.get: - response = await http.get(url, headers: headers); - break; - case HttpMethod.post: - response = await http.post(url, headers: headers, body: body); - break; - case HttpMethod.put: - response = await http.put(url, headers: headers, body: body); - break; - case HttpMethod.delete: - response = await http.delete(url, headers: headers, body: body); - break; - default: - throw Exception('Unsupported HTTP method'); + switch (method) { + case HttpMethod.get: + response = + await client.get(url, headers: headers).timeout(_requestTimeout); + break; + case HttpMethod.post: + response = await client + .post(url, headers: headers, body: body) + .timeout(_requestTimeout); + break; + case HttpMethod.put: + response = await client + .put(url, headers: headers, body: body) + .timeout(_requestTimeout); + break; + case HttpMethod.delete: + response = await client + .delete(url, headers: headers, body: body) + .timeout(_requestTimeout); + break; + default: + throw Exception('Unsupported HTTP method'); + } + + return response; + } finally { + client.close(); } - - return response; } Future _pollForResponse(String url) async {