From bb737a9563075063dd0ff45b9ff289a0af33ca19 Mon Sep 17 00:00:00 2001 From: Mike de Geofroy Date: Wed, 17 Jun 2026 07:46:23 +0300 Subject: [PATCH] Serve /.well-known app-association files for mobile deep links onec-ui-starter now serves apple-app-site-association and assetlinks.json so iOS/Android open the onec-mobile app from a server's https:// links (Universal Links / App Links). - WellKnownController + AppLinksProperties (prefix onec.app-links), wired in UiAutoConfiguration. Public, unauthenticated, application/json, no redirect; both 404 until configured so an unconfigured server advertises no association. - Docs (ARCHITECTURE endpoint table, CONFIGURATION, ui-starter README) and a commented example in the example app. Server half of onec-erp/onec-mobile#5. Co-Authored-By: Claude Opus 4.8 --- build.gradle.kts | 2 +- docs/ARCHITECTURE.md | 5 +- docs/CONFIGURATION.md | 4 +- example/src/main/resources/application.yaml | 11 +++ onec-ui-starter/README.md | 34 +++++++ .../java/com/onec/ui/AppLinksProperties.java | 87 ++++++++++++++++++ .../java/com/onec/ui/UiAutoConfiguration.java | 11 ++- .../java/com/onec/ui/WellKnownController.java | 91 +++++++++++++++++++ .../com/onec/ui/WellKnownControllerTest.java | 78 ++++++++++++++++ 9 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 onec-ui-starter/src/main/java/com/onec/ui/AppLinksProperties.java create mode 100644 onec-ui-starter/src/main/java/com/onec/ui/WellKnownController.java create mode 100644 onec-ui-starter/src/test/java/com/onec/ui/WellKnownControllerTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 0cf826e8..3def3fa0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -239,7 +239,7 @@ configure(subprojects.filter { it.name in configModuleProjects }) { } val configModuleTitles = mapOf( "onec-framework-starter" to "Core — `onec-framework-starter` (`OnecProperties`, prefix `onec`)", - "onec-ui-starter" to "UI — `onec-ui-starter` (`UiProperties` prefix `onec.ui`, `MediaProperties` prefix `onec.media`, `CommentProperties` prefix `onec.comments`)", + "onec-ui-starter" to "UI — `onec-ui-starter` (`UiProperties` prefix `onec.ui`, `MediaProperties` prefix `onec.media`, `CommentProperties` prefix `onec.comments`, `AppLinksProperties` prefix `onec.app-links`)", "onec-auth-starter" to "Auth — `onec-auth-starter` (`OnecAuthProperties`, prefix `onec.auth`)", "onec-mcp-starter" to "MCP — `onec-mcp-starter` (`OnecMcpProperties`, prefix `onec.mcp`)", "onec-import-starter" to "Import — `onec-import-starter` (`OnecImportProperties`, prefix `onec.import`)", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 770bca08..92666065 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -184,6 +184,7 @@ contract (column-name keys, `{col}_display`/`{col}_ref` expansion, `__SECRET_SET | Mentions | `GET /api/mentions?q=` — cross-entity `@`-mention typeahead over readable catalogs/documents; comment bodies carry mentions as `@[Display](kind/name/id)` tokens resolved live, and each readable mention publishes `EntityMentionedEvent` (no consumers — additive via `@EventListener`) (ui-starter) | | DivKit UI | `GET /api/divkit/{shell,home,menu,account,settings}` and `/api/divkit/{catalogs,documents}/{name}[/{id}|/new|/{id}/edit]`, `/api/divkit/registers/{name}` (ui-starter) | | Theme/config | `GET /api/theme`, `GET /api/config`, `GET /api/branding` (ui-starter) | +| App links | `GET /.well-known/apple-app-site-association`, `GET /.well-known/assetlinks.json` — public, unauthenticated `application/json` so iOS/Android verify the domain and open the mobile app from its links; 404 until `onec.app-links` is set (ui-starter) | | Events | `GET /api/events` — SSE stream of CRUD/posting changes (ui-starter) | | Auth | `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`, `GET /api/auth/csrf` (auth-starter) | | Import | `POST /api/import/{catalogs,documents}/{name}/csv[/preview]` (import-starter) | @@ -235,7 +236,9 @@ and `config(key,value)` reference. `/api/**` requires authentication (except the public allowlist: `/error`, `/api/theme`, `/api/config`, `/api/branding`, `/api/auth/login`, `/api/auth/me`, `/api/auth/csrf`, -`/api/divkit/login`, `/api/desktop/**`). **Per-entity RBAC is deny-by-default**: a +`/api/divkit/login`, `/api/desktop/**`). Everything outside `/api/**` is public — including the SPA +shell and the `/.well-known/*` app-association files (which the OS must fetch unauthenticated and +without a redirect). **Per-entity RBAC is deny-by-default**: a catalog/document/register is invisible and uneditable unless its `@AccessControl` read/write roles grant the caller; the `ADMIN` role is a superuser. Override the whole thing by setting `onec.auth.enabled=false` and supplying your own diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a79ac47d..7740fd61 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -25,10 +25,12 @@ except Kafka inbound). Standard Spring keys (`spring.datasource.*`, `spring.mail | `onec.schema.mode` | `String` | `apply` | What to do about differences between the metadata model and the database at startup: `apply` (default — execute safe changes, report destructive ones), `plan` (log the plan, change nothing), `validate` (fail startup on any difference or unapplied migration), or `off`. | | `onec.security.secret-key` | `String` | — | Encryption key for `@Attribute(secret = true)` values. Any passphrase works (it is hashed to a 256-bit AES key). Required only when an entity declares a secret attribute; supply it from an environment variable, never hard-code it. | -## UI — `onec-ui-starter` (`UiProperties` prefix `onec.ui`, `MediaProperties` prefix `onec.media`, `CommentProperties` prefix `onec.comments`) +## UI — `onec-ui-starter` (`UiProperties` prefix `onec.ui`, `MediaProperties` prefix `onec.media`, `CommentProperties` prefix `onec.comments`, `AppLinksProperties` prefix `onec.app-links`) | Property | Type | Default | Meaning | | --- | --- | --- | --- | +| `onec.app-links.android` | `List` | — | Android apps allowed to handle this domain's links, served in `/.well-known/assetlinks.json`. Empty → that endpoint 404s. | +| `onec.app-links.apple-app-ids` | `List` | — | Apple `appID`s (each `TeamID.bundleId`) allowed to handle this domain's links, served in `/.well-known/apple-app-site-association`. Empty → that endpoint 404s. | | `onec.comments.enabled` | `Boolean` | `true` | Whether the comments endpoint, its storage table, and the detail-page comments panel are wired at all. Turn it off to drop the feature from every entity without touching the model. | | `onec.comments.max-length` | `Integer` | `4000` | Largest comment body accepted, in characters. The server rejects a longer body with 422; the compose box mirrors the limit client-side. Defaults to 4000. | | `onec.comments.mentions.enabled` | `Boolean` | `true` | Whether `@`-mentions are parsed, resolved and offered in the compose typeahead. Turn it off to keep plain-text comments without touching `onec.comments.enabled`; existing mention tokens then degrade to their plain label text. | diff --git a/example/src/main/resources/application.yaml b/example/src/main/resources/application.yaml index c426acf2..4ebb2568 100644 --- a/example/src/main/resources/application.yaml +++ b/example/src/main/resources/application.yaml @@ -18,6 +18,17 @@ onec: # composes its own SettingsPage at /settings, so turn the surface + admin nav entry on. settings: enabled: true + # Let the onec-mobile app open from this server's https:// links (iOS Universal Links / Android + # App Links). The two /.well-known files 404 until set; values are deployment-specific (Apple Team + # ID, app bundle id / package, signing-cert SHA-256). Self-hosted servers can set these, but the + # app only registers *.cloud.onno.su at build time — others fall back to the onec:// scheme / QR. + # app-links: + # apple-app-ids: + # - "ABCDE12345.su.onno.onec.mobile" # . + # android: + # - package-name: su.onno.onec.mobile + # sha256-cert-fingerprints: + # - "AB:CD:EF:..." auth: # Authentication backend. Default is "in-memory" (the onec.auth.users below). Set to # "oidc" for server-side OIDC login (Keycloak, Zitadel, …; keeps the session cookie, "Sign in" diff --git a/onec-ui-starter/README.md b/onec-ui-starter/README.md index b31689bc..b43ec62b 100644 --- a/onec-ui-starter/README.md +++ b/onec-ui-starter/README.md @@ -43,6 +43,8 @@ controller, and a static-resource handler that serves the bundled frontend from | `onec.ui.update-check.url` | `https://cloud.onno.su/releases/v1/latest` | Release-announcement endpoint to poll. | | `onec.ui.update-check.interval` | `24h` | Cadence after the first check (floored at 60s). | | `onec.ui.update-check.initial-delay` | `1m` | Delay before the first check. | +| `onec.app-links.apple-app-ids` | empty | iOS `appID`s (`.`) served from `/.well-known/apple-app-site-association` for Universal Links. Empty → 404. | +| `onec.app-links.android` | empty | Android apps (`package-name` + `sha256-cert-fingerprints`) served from `/.well-known/assetlinks.json` for App Links. Empty → 404. | ### Update-available notice @@ -55,6 +57,38 @@ unparseable body, unknown local version) is swallowed, so a flaky network never alarming notice. The endpoint it polls is served by onec-cloud (`GET /releases/v1/latest`), which returns `204` when no release is announced. +### Mobile app links — `/.well-known` + +So a native app (e.g. [onec-mobile](https://github.com/onec-erp/onec-mobile)) can open from this +domain's `https://` links instead of the browser, the starter serves the two association files the +OS fetches: + +| Endpoint | For | +|----------|-----| +| `GET /.well-known/apple-app-site-association` | iOS Universal Links | +| `GET /.well-known/assetlinks.json` | Android App Links | + +Both are **public, unauthenticated `application/json`, no redirect** — the OS fetches them anonymously +and rejects the association on any login wall or redirect. They live outside `/api/**` (public by +default) and, being explicit controller mappings, win over the SPA's `index.html` fallback. Each +answers `404` until configured, so an unconfigured deployment advertises no association: + +```yaml +onec: + app-links: + apple-app-ids: + - "ABCDE12345.su.onno.onec.mobile" # . + android: + - package-name: su.onno.onec.mobile + sha256-cert-fingerprints: + - "AB:CD:EF:..." # signing-cert SHA-256 +``` + +The values are deployment-specific (Apple Team ID, app bundle id / package, signing-cert +fingerprint). The cloud serves these from every tenant subdomain automatically; self-hosted servers +on their own domains can set them too, but the app only registers the cloud's parent domain at build +time, so self-hosted onboarding uses the app's `onec://` custom scheme / QR instead. + ## REST endpoints All endpoints are under `/api/**`. Entity-scoped paths take a `{name}` that is the entity's diff --git a/onec-ui-starter/src/main/java/com/onec/ui/AppLinksProperties.java b/onec-ui-starter/src/main/java/com/onec/ui/AppLinksProperties.java new file mode 100644 index 00000000..555e87a1 --- /dev/null +++ b/onec-ui-starter/src/main/java/com/onec/ui/AppLinksProperties.java @@ -0,0 +1,87 @@ +package com.onec.ui; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +/** + * App-association data served from the two {@code /.well-known} files so a native mobile app can + * claim this domain's {@code https://} links (iOS Universal Links, Android App Links) and open + * directly instead of the browser. Both lists are empty by default, in which case the corresponding + * endpoint answers {@code 404} — universal links are opt-in and require deployment-specific identity + * and signing values that only the operator has. + * + *
+ * onec:
+ *   app-links:
+ *     apple-app-ids:                 # iOS: "<TeamID>.<bundleId>"
+ *       - "ABCDE12345.su.onno.onec.mobile"
+ *     android:                       # Android: package + signing-cert SHA-256 fingerprint(s)
+ *       - package-name: su.onno.onec.mobile
+ *         sha256-cert-fingerprints:
+ *           - "AB:CD:EF:..."
+ * 
+ * + * @see WellKnownController + */ +@ConfigurationProperties(prefix = "onec.app-links") +public class AppLinksProperties { + + /** + * Apple {@code appID}s (each {@code TeamID.bundleId}) allowed to handle this domain's links, + * served in {@code /.well-known/apple-app-site-association}. Empty → that endpoint 404s. + */ + private List appleAppIds = new ArrayList<>(); + + /** + * Android apps allowed to handle this domain's links, served in {@code /.well-known/assetlinks.json}. + * Empty → that endpoint 404s. + */ + private List android = new ArrayList<>(); + + public List getAppleAppIds() { + return appleAppIds; + } + + public void setAppleAppIds(List appleAppIds) { + this.appleAppIds = appleAppIds; + } + + public List getAndroid() { + return android; + } + + public void setAndroid(List android) { + this.android = android; + } + + /** One Android app's identity in an {@code assetlinks.json} statement. */ + public static class AndroidApp { + + /** The application package name, e.g. {@code su.onno.onec.mobile}. */ + private String packageName; + + /** + * SHA-256 fingerprints of the signing certificate(s) (upper-case, colon-separated). Multiple + * allow rotating keys or distinct debug/release/upload certs to all verify the same domain. + */ + private List sha256CertFingerprints = new ArrayList<>(); + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public List getSha256CertFingerprints() { + return sha256CertFingerprints; + } + + public void setSha256CertFingerprints(List sha256CertFingerprints) { + this.sha256CertFingerprints = sha256CertFingerprints; + } + } +} diff --git a/onec-ui-starter/src/main/java/com/onec/ui/UiAutoConfiguration.java b/onec-ui-starter/src/main/java/com/onec/ui/UiAutoConfiguration.java index 6efa5ad6..7ee31f2e 100644 --- a/onec-ui-starter/src/main/java/com/onec/ui/UiAutoConfiguration.java +++ b/onec-ui-starter/src/main/java/com/onec/ui/UiAutoConfiguration.java @@ -15,7 +15,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @AutoConfiguration(after = OnecAutoConfiguration.class) -@EnableConfigurationProperties({UiProperties.class, UpdateProperties.class}) +@EnableConfigurationProperties({UiProperties.class, UpdateProperties.class, AppLinksProperties.class}) @ConditionalOnBean(MetadataRegistry.class) @ConditionalOnProperty(prefix = "onec.ui", name = "enabled", havingValue = "true", matchIfMissing = true) public class UiAutoConfiguration implements WebMvcConfigurer { @@ -41,6 +41,15 @@ public SpaIndexController spaIndexController() { return new SpaIndexController(spaIndexHtml); } + /** + * Serves the {@code /.well-known} app-association files (iOS Universal Links / Android App + * Links). Always wired; the endpoints 404 until {@code onec.app-links} is configured. + */ + @Bean + public WellKnownController wellKnownController(AppLinksProperties appLinksProperties) { + return new WellKnownController(appLinksProperties); + } + @Bean public SettingsController settingsController(MetadataRegistry registry, com.onec.repository.ConstantManager constantManager, diff --git a/onec-ui-starter/src/main/java/com/onec/ui/WellKnownController.java b/onec-ui-starter/src/main/java/com/onec/ui/WellKnownController.java new file mode 100644 index 00000000..0429b915 --- /dev/null +++ b/onec-ui-starter/src/main/java/com/onec/ui/WellKnownController.java @@ -0,0 +1,91 @@ +package com.onec.ui; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Serves the two {@code /.well-known} app-association files that let a native mobile app claim this + * domain's {@code https://} links — iOS Universal Links and Android App Links — so a tapped link + * opens the app instead of the browser. + * + *

These must be reachable unauthenticated, as {@code application/json}, with no redirect: + * the OS fetches them anonymously and rejects the association on any auth wall or redirect. They sit + * outside {@code /api/**}, which the auth starter leaves public by default, and being an explicit + * controller mapping they take precedence over the SPA's {@code /**} index.html fallback (which would + * otherwise answer these paths with HTML). + * + *

Both endpoints answer {@code 404} until the matching identities are configured under + * {@code onec.app-links} (see {@link AppLinksProperties}) — so an unconfigured deployment advertises + * no association rather than a broken/empty one. + */ +@RestController +public class WellKnownController { + + private final AppLinksProperties properties; + + public WellKnownController(AppLinksProperties properties) { + this.properties = properties; + } + + /** + * iOS {@code apple-app-site-association} (note: no file extension). Grants the configured + * {@code appID}s Universal-Link handling for every path on this domain. + */ + @GetMapping(value = "/.well-known/apple-app-site-association", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> appleAppSiteAssociation() { + List appIds = properties.getAppleAppIds(); + if (appIds == null || appIds.isEmpty()) { + return ResponseEntity.notFound().build(); + } + Map detail = new LinkedHashMap<>(); + detail.put("appIDs", new ArrayList<>(appIds)); + // A single component matching every path. `components` (iOS 13+) is the modern form; the + // wildcard-subdomain entitlement this pairs with already requires iOS 14+. + detail.put("components", List.of(Map.of("/", "*", "comment", "Open every path in the app"))); + + Map applinks = new LinkedHashMap<>(); + applinks.put("apps", List.of()); // required-empty in the legacy schema; harmless for new parsers + applinks.put("details", List.of(detail)); + + return ResponseEntity.ok().body(Map.of("applinks", applinks)); + } + + /** + * Android {@code assetlinks.json}. One {@code handle_all_urls} statement per configured app, + * binding its package + signing-cert fingerprint(s) to this domain. + */ + @GetMapping(value = "/.well-known/assetlinks.json", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> assetLinks() { + List apps = properties.getAndroid(); + if (apps == null || apps.isEmpty()) { + return ResponseEntity.notFound().build(); + } + List> statements = new ArrayList<>(); + for (AppLinksProperties.AndroidApp app : apps) { + if (app.getPackageName() == null || app.getPackageName().isBlank() + || app.getSha256CertFingerprints() == null || app.getSha256CertFingerprints().isEmpty()) { + continue; // an entry missing its package or fingerprints can't verify — skip it + } + Map target = new LinkedHashMap<>(); + target.put("namespace", "android_app"); + target.put("package_name", app.getPackageName()); + target.put("sha256_cert_fingerprints", new ArrayList<>(app.getSha256CertFingerprints())); + + Map statement = new LinkedHashMap<>(); + statement.put("relation", List.of("delegate_permission/common.handle_all_urls")); + statement.put("target", target); + statements.add(statement); + } + if (statements.isEmpty()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok().body(statements); + } +} diff --git a/onec-ui-starter/src/test/java/com/onec/ui/WellKnownControllerTest.java b/onec-ui-starter/src/test/java/com/onec/ui/WellKnownControllerTest.java new file mode 100644 index 00000000..993172f0 --- /dev/null +++ b/onec-ui-starter/src/test/java/com/onec/ui/WellKnownControllerTest.java @@ -0,0 +1,78 @@ +package com.onec.ui; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The two app-association files: 404 until configured, and once configured a payload shaped exactly + * as iOS / Android expect (so the OS verifies the association and routes links to the app). + */ +class WellKnownControllerTest { + + @Test + void unconfiguredEndpointsReturn404() { + WellKnownController controller = new WellKnownController(new AppLinksProperties()); + + assertThat(controller.appleAppSiteAssociation().getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(controller.assetLinks().getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @SuppressWarnings("unchecked") + void appleAppSiteAssociationCarriesConfiguredAppIds() { + AppLinksProperties props = new AppLinksProperties(); + props.setAppleAppIds(List.of("ABCDE12345.su.onno.onec.mobile")); + + ResponseEntity> response = new WellKnownController(props).appleAppSiteAssociation(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + Map applinks = (Map) response.getBody().get("applinks"); + List> details = (List>) applinks.get("details"); + assertThat(details).hasSize(1); + assertThat((List) details.get(0).get("appIDs")) + .containsExactly("ABCDE12345.su.onno.onec.mobile"); + // Matches every path on the domain. + List> components = (List>) details.get(0).get("components"); + assertThat(components.get(0).get("/")).isEqualTo("*"); + } + + @Test + @SuppressWarnings("unchecked") + void assetLinksCarriesPackageAndFingerprints() { + AppLinksProperties.AndroidApp app = new AppLinksProperties.AndroidApp(); + app.setPackageName("su.onno.onec.mobile"); + app.setSha256CertFingerprints(List.of("AB:CD:EF:00:11:22")); + AppLinksProperties props = new AppLinksProperties(); + props.setAndroid(List.of(app)); + + ResponseEntity>> response = new WellKnownController(props).assetLinks(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).hasSize(1); + + Map statement = response.getBody().get(0); + assertThat((List) statement.get("relation")) + .containsExactly("delegate_permission/common.handle_all_urls"); + Map target = (Map) statement.get("target"); + assertThat(target.get("namespace")).isEqualTo("android_app"); + assertThat(target.get("package_name")).isEqualTo("su.onno.onec.mobile"); + assertThat((List) target.get("sha256_cert_fingerprints")) + .containsExactly("AB:CD:EF:00:11:22"); + } + + @Test + void androidEntryMissingFingerprintsIsSkipped() { + AppLinksProperties.AndroidApp app = new AppLinksProperties.AndroidApp(); + app.setPackageName("su.onno.onec.mobile"); // no fingerprints → can't verify + AppLinksProperties props = new AppLinksProperties(); + props.setAndroid(List.of(app)); + + assertThat(new WellKnownController(props).assetLinks().getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } +}