Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions controller/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,15 @@
android:value="Removes selected library content from the co-located server to reclaim device storage." />
</service>

<service
android:name=".contentexchange.presentation.ContentShareService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Shares selected library content to a nearby device over a local-only connection to the co-located server." />
</service>

<!-- ADFA-5333: runs the LIVE dash-node update in the background (notification only) instead of
behind a blocking modal. Same shape as the download services above: not exported, and
specialUse because it drives the in-server blue-green rebuild rather than a stock transfer. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
package org.appdevforall.k2go.contentexchange.data;

import org.appdevforall.k2go.config.BoxEndpoints;
import org.appdevforall.k2go.contentexchange.domain.ItemRef;
import org.json.JSONArray;
import org.json.JSONObject;

import java.io.ByteArrayOutputStream;
Expand All @@ -22,6 +24,7 @@
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;

/**
* The one place in this feature that touches {@link HttpURLConnection}. It issues
Expand Down Expand Up @@ -138,7 +141,66 @@ public JSONObject deleteExportOp(String opId) throws Exception {
return deleteJson(V1 + "/export/" + encodeSegment(opId));
}

// ---- share gate (K2GO-376) -----------------------------------------------------------------
// These hit the sender's OWN cx-core over the loopback relay. The receiver reaches the same
// session over the hotspot (a separate off-loopback exposure); the app never copies bytes. The
// token minted here is the session's only auth and travels in the X-Share-Token header.

/**
* {@code POST /v1/share} with {@code {items:[{app,localRef}]}} -> {@code {sessionId, token,
* manifestUrl}}. Opens a scoped share session on the local cx-core and returns the token the
* sender puts in the QR; no token header (this call mints it). K2GO-376.
*/
public ShareCreated postShareCreate(List<ItemRef> items) throws Exception {
JSONArray arr = new JSONArray();
for (ItemRef ref : items) {
arr.put(new JSONObject().put("app", ref.app()).put("localRef", ref.localRef()));
}
JSONObject body = new JSONObject().put("items", arr);
JSONObject res = postJson(V1 + "/share", body, null);
return new ShareCreated(
res.optString("sessionId", ""),
res.optString("token", ""),
res.optString("manifestUrl", ""));
}

/**
* {@code POST /v1/share/{sessionId}/complete} (X-Share-Token) -> {@code {ok}}. The clean-success
* close; the receiver drives it. A 404 means already closed (harmless). K2GO-376.
*/
public JSONObject postShareComplete(String sessionId, String token) throws Exception {
return postJson(V1 + "/share/" + encodeSegment(sessionId) + "/complete", new JSONObject(), token);
}

/**
* {@code DELETE /v1/share/{sessionId}} (X-Share-Token) -> {@code {ok}}. Cancel / abort, by host
* or receiver; the sender's foreground service calls it on teardown (the app-side closer). A 404
* means already closed, so a double close is harmless. K2GO-376.
*/
public JSONObject deleteShare(String sessionId, String token) throws Exception {
return deleteJson(V1 + "/share/" + encodeSegment(sessionId), token);
}

/** The result of opening a share session: the id, the auth token, and the manifest path. */
public static final class ShareCreated {
public final String sessionId;
public final String token;
public final String manifestUrl;

ShareCreated(String sessionId, String token, String manifestUrl) {
this.sessionId = sessionId;
this.token = token;
this.manifestUrl = manifestUrl;
}
}

private static JSONObject postJson(String urlStr, JSONObject body) throws Exception {
return postJson(urlStr, body, null);
}

/** POST with an optional {@code X-Share-Token} header (K2GO-376: the token is the gate's only
* auth, so it rides a header, never the URL query, to stay out of relay / nginx access logs). */
private static JSONObject postJson(String urlStr, JSONObject body, String shareToken) throws Exception {
HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection();
try {
c.setUseCaches(false);
Expand All @@ -148,6 +210,9 @@ private static JSONObject postJson(String urlStr, JSONObject body) throws Except
c.setDoOutput(true);
c.setRequestProperty("Content-Type", "application/json");
c.setRequestProperty("Accept", "application/json");
if (shareToken != null) {
c.setRequestProperty("X-Share-Token", shareToken);
}
byte[] payload = body.toString().getBytes(StandardCharsets.UTF_8);
try (OutputStream os = c.getOutputStream()) {
os.write(payload);
Expand All @@ -165,13 +230,21 @@ private static JSONObject postJson(String urlStr, JSONObject body) throws Except
}

private static JSONObject deleteJson(String urlStr) throws Exception {
return deleteJson(urlStr, null);
}

/** DELETE with an optional {@code X-Share-Token} header (K2GO-376; see {@link #postJson}). */
private static JSONObject deleteJson(String urlStr, String shareToken) throws Exception {
HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection();
try {
c.setUseCaches(false);
c.setConnectTimeout(CONNECT_TIMEOUT_MS);
c.setReadTimeout(DELETE_READ_TIMEOUT_MS);
c.setRequestMethod("DELETE");
c.setRequestProperty("Accept", "application/json");
if (shareToken != null) {
c.setRequestProperty("X-Share-Token", shareToken);
}
int code = c.getResponseCode();
boolean ok = code >= 200 && code < 400;
String text = readAll(ok ? c.getInputStream() : c.getErrorStream());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* ============================================================================
* Name : ShareHandshake.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : K2GO-376. The Share QR payload schema (encode + parse), a variant of the sync
* handshake (SyncHandshakeHelper) for the content-exchange feature. Encode is the
* sender side (build the invite to render); parse is the receiver side; both go
* through ShareInviteValidator, so one class owns the invite contract for both forks.
* ============================================================================
*/
package org.appdevforall.k2go.contentexchange.data;

import org.appdevforall.k2go.contentexchange.domain.ShareInviteValidator;
import org.appdevforall.k2go.contentexchange.domain.ShareTransport;
import org.appdevforall.k2go.sync.domain.SyncCredentialValidator;
import org.json.JSONObject;

/**
* Encodes and parses the Share invite QR (ADR "Pillar Share"). The payload is small JSON:
*
* <pre>{"app":"iiab_share","addr":"&lt;host&gt;:&lt;port&gt;","token":"&lt;t&gt;","manifestUrl":"/v1/share/&lt;id&gt;/manifest"}</pre>
*
* <p>{@code addr} is the sender's hotspot IP and the share port joined with a colon (the one field
* the ADR calls the address). The receiver composes {@code http://addr + manifestUrl} for the
* manifest and {@code http://addr + /v1/share/<id>/fetch/<ref>} for each item, with the token in the
* {@code X-Share-Token} header (never the URL). Parsing is fail-closed: an invite that does not
* validate returns {@code null}, mirroring {@code SyncHandshakeHelper.parsePayload}.
*/
public final class ShareHandshake {

private ShareHandshake() {
}

/** A parsed, validated Share invite: the host and port split out of {@code addr}, plus the token
* and manifest path. {@link #baseUrl()} is the origin the receiver prefixes to share paths. */
public static final class ShareInvite {
public final String host;
public final int port;
public final String token;
public final String manifestUrl;

public ShareInvite(String host, int port, String token, String manifestUrl) {
this.host = host;
this.port = port;
this.token = token;
this.manifestUrl = manifestUrl;
}

/** {@code http://host:port} (no trailing slash): the receiver appends the share paths. */
public String baseUrl() {
return ShareTransport.SCHEME + "://" + host + ":" + port;
}
}

/**
* Build the QR payload for a validated invite, or {@code null} when the inputs do not validate
* (so a malformed address or token is never rendered into a scannable code). {@code host} is the
* sender's hotspot IPv4; {@code port} is {@link ShareTransport#SHARE_PORT}.
*/
public static String encode(String host, int port, String token, String manifestUrl) {
SyncCredentialValidator.Result check =
ShareInviteValidator.validate(host, port, token, manifestUrl);
if (!check.valid) {
return null;
}
try {
JSONObject json = new JSONObject();
json.put("app", ShareTransport.QR_APP_TAG);
json.put("addr", host + ":" + port);
json.put("token", token);
json.put("manifestUrl", manifestUrl);
return json.toString();
} catch (Exception e) {
return null;
}
}

/**
* Parse a scanned payload into a validated invite, or {@code null} when it is not a Share QR or
* fails validation. The receiver fork calls this at the scan boundary; the scanned text is
* untrusted input, so every field passes {@link ShareInviteValidator} before it is returned.
*/
public static ShareInvite decode(String scannedJson) {
try {
JSONObject json = new JSONObject(scannedJson);
if (!ShareTransport.QR_APP_TAG.equals(json.optString("app"))) {
return null; // not a Share invite
}
String addr = json.optString("addr", "");
String token = json.optString("token", "");
String manifestUrl = json.optString("manifestUrl", "");
int colon = addr.lastIndexOf(':'); // IPv4 has no colons, so the last ':' splits host:port
if (colon <= 0 || colon == addr.length() - 1) {
return null;
}
String host = addr.substring(0, colon);
int port = parsePort(addr.substring(colon + 1));
SyncCredentialValidator.Result check =
ShareInviteValidator.validate(host, port, token, manifestUrl);
if (!check.valid) {
return null;
}
return new ShareInvite(host, port, token, manifestUrl);
} catch (Exception e) {
return null;
}
}

/** Parse the port digits, returning -1 (an invalid port) on any non-numeric or out-of-range text. */
private static int parsePort(String s) {
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
return -1;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* ============================================================================
* Name : ShareInviteValidator.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : K2GO-376. Domain rule: what counts as a *safe* Share invite (the QR payload the
* sender shows and the receiver scans). Pure (no android.*, no JSON); the single
* source for the invite shape, so the sender and the receiver fork validate the
* same thing. Reuses the host/port rule from the sync security slice (S1).
* ============================================================================
*/
package org.appdevforall.k2go.contentexchange.domain;

import org.appdevforall.k2go.sync.domain.SyncCredentialValidator;

/**
* Pure validation of a Share invite (ADR "Pillar Share"). The invite carries the sender's reachable
* address and port, a session token, and the manifest path. On the sender these come from trusted
* local sources (the hotspot owner and the sender's own cx-core), but the RECEIVER scans the same
* QR as untrusted input, and both forks build against this one rule so the contract cannot drift.
*
* <p>The receiver later composes URLs from these fields ({@code http://host:port} + a share path),
* so the host/port rule is reused from {@link SyncCredentialValidator} (the hardened S1 rule) and
* the token/path are held to conservative, injection-free character sets.
*/
public final class ShareInviteValidator {

/** The cx-core token is {@code secrets.token_urlsafe(32)}: url-safe base64, ~43 chars. */
private static final int MAX_TOKEN_LEN = 256;
/** The manifest path is short ({@code /v1/share/<id>/manifest}); cap it well above that. */
private static final int MAX_PATH_LEN = 512;

private ShareInviteValidator() {
}

/** Validate every field of an invite. Call this at the build boundary (sender) and the scan
* boundary (receiver); reject the whole invite when it returns invalid. */
public static SyncCredentialValidator.Result validate(String host, int port, String token,
String manifestUrl) {
if (!SyncCredentialValidator.isValidHost(host)) {
return SyncCredentialValidator.Result.fail("invalid host");
}
if (!SyncCredentialValidator.isValidPort(port)) {
return SyncCredentialValidator.Result.fail("invalid port");
}
if (!isValidToken(token)) {
return SyncCredentialValidator.Result.fail("invalid token");
}
if (!isValidManifestPath(manifestUrl)) {
return SyncCredentialValidator.Result.fail("invalid manifest path");
}
return SyncCredentialValidator.Result.ok();
}

/**
* A token safe to place in an HTTP header and compare: a non-empty run of url-safe base64
* characters ({@code [A-Za-z0-9-_]}), which is exactly what {@code token_urlsafe} produces. No
* whitespace or control characters, so it cannot smuggle a second header line.
*/
public static boolean isValidToken(String token) {
if (token == null || token.isEmpty() || token.length() > MAX_TOKEN_LEN) {
return false;
}
for (int i = 0; i < token.length(); i++) {
char c = token.charAt(i);
boolean ok = (c >= 'A' && c <= 'Z')
|| (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9')
|| c == '-'
|| c == '_';
if (!ok) {
return false;
}
}
return true;
}

/**
* A server-relative path safe to append to {@code http://host:port}: it must start with a single
* {@code /}, carry no whitespace or control characters, no {@code ..} segment, and no scheme or
* authority ({@code ://}), so it cannot redirect the receiver to another host.
*/
public static boolean isValidManifestPath(String path) {
if (path == null || path.length() < 2 || path.length() > MAX_PATH_LEN) {
return false;
}
if (path.charAt(0) != '/' || path.charAt(1) == '/') {
return false; // must be a single-slash absolute path, not "//host" (an authority)
}
if (path.contains("..") || path.contains("://")) {
return false;
}
for (int i = 0; i < path.length(); i++) {
char c = path.charAt(i);
if (c <= 0x20 || c >= 0x7f) {
return false; // no control characters, space or DEL
}
}
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* ============================================================================
* Name : ShareTransport.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : K2GO-376. One source for the content-exchange wireless-share transport
* constants: the off-loopback port the receiver reaches the sender on, the
* scheme, and the QR application tag. Pure (no android.*); the sender and the
* later receiver fork both read it so the two forks cannot drift apart.
* ============================================================================
*/
package org.appdevforall.k2go.contentexchange.domain;

/**
* Transport settings for granular wireless Share (K2GO-376).
*
* <p>Share is the network twin of Export: the receiver PULLS from the sender's live cx-core
* (ADR "Pillar Share"). On the sender the share session is created over the loopback relay, but
* the RECEIVER reaches the gate over the hotspot at {@code http://<senderHotspotIp>:SHARE_PORT}.
* That off-loopback exposure is a separate server-side slice (plain HTTP, v1 only); this is the
* single constant the app points at it, so it stays in step with the exposure port.
*/
public final class ShareTransport {

private ShareTransport() {
}

/**
* TCP port the sender's cx-core is exposed on, off loopback, for the paired receiver. Must
* match the scoped-exposure slice (candidate 8095, plain HTTP for v1). The loopback relay the
* sender uses to CREATE the session is unrelated to this port.
*/
public static final int SHARE_PORT = 8095;

/** The receiver reaches the gate over plain HTTP on the hotspot (no TLS for v1; see the ADR). */
public static final String SCHEME = "http";

/** QR payload {@code app} tag, so a scanned code is recognised as a Share invite (not a Sync QR). */
public static final String QR_APP_TAG = "iiab_share";
}
Loading
Loading