Contact links open the app: /add, apple-app-site-association, assetlinks.json
SPEC 8.12: a contact link's web form is https://<relay>/add#u=..&t|l=..&h=.. and its payload is in the fragment, which no browser sends. - GET /add: one page for every link, rendered with nothing about anybody. add.js reads the fragment in the browser and points "Open in the app" at the flamenet: form both apps open; without a fragment it says the link is incomplete. Install links as on /join. noindex, no-store, no-referrer; the script is a file, so the CSP's default-src 'self' allows it. - /.well-known/apple-app-site-association: M5923Y84P5.net.flamenet.messenger, components /add* only. - /.well-known/assetlinks.json: io.flamenet.messenger and the Messenger release certificate's SHA-256 (the public fingerprint, 79:1F:..:3A:1D). - Both name the published app, so they are constants, not configuration. ContactLinkPageTests: 3 red, then 3/3 green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8ebf83f62c
commit
30a987d5e1
@@ -0,0 +1,24 @@
|
||||
// SPEC 8.12: https://<relay>/add#u=<id>&t=<token>|l=<link>&h=<handle>.
|
||||
// The fragment never reaches the server; this script hands it to the app as
|
||||
// the flamenet: form, flamenet:@<relay>?<the same fields>, which both apps
|
||||
// open. Nothing here is sent anywhere.
|
||||
(function () {
|
||||
var open = document.getElementById('add-open');
|
||||
var missing = document.getElementById('add-missing');
|
||||
var who = document.getElementById('add-who');
|
||||
var fields = (location.hash || '').replace(/^#/, '');
|
||||
var params = new URLSearchParams(fields);
|
||||
if (!fields || !params.get('u') || (!params.get('t') && !params.get('l'))) {
|
||||
if (missing) missing.hidden = false;
|
||||
if (open) open.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (open) open.href = 'flamenet:@' + location.hostname + '?' + fields;
|
||||
var handle = params.get('h');
|
||||
if (handle && who) {
|
||||
// A hint the link carries, not checked by anybody yet: textContent only.
|
||||
who.textContent =
|
||||
'The link says it is from ' + handle + '. Open it in Flamenet Messenger to start a ' +
|
||||
'conversation. The app checks the name when they reply.';
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,53 @@
|
||||
#extend("layout"):
|
||||
#export("content"):
|
||||
|
||||
<section class="band band-hero">
|
||||
<div class="wrap">
|
||||
<h1>Somebody shared a contact link</h1>
|
||||
<p class="band-lede" id="add-who">
|
||||
It lets you start a conversation with them in
|
||||
<span class="hl">Flamenet Messenger</span>, without knowing their username.
|
||||
</p>
|
||||
<p id="add-missing" hidden>
|
||||
This link has nothing after the <code>#</code>, so there is nobody to open.
|
||||
Ask the person who sent it for the whole link.
|
||||
</p>
|
||||
<div class="platforms">
|
||||
<a class="platform" id="add-open" href="/get" rel="nofollow">
|
||||
<span class="platform-name">Open in the app</span>
|
||||
<span class="platform-state">Flamenet Messenger</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="band">
|
||||
<div class="wrap">
|
||||
<h2>No app yet?</h2>
|
||||
<div class="platforms">
|
||||
#if(iosLive && iosStoreURL != ""):<a class="platform" href="#(iosStoreURL)" rel="noopener"><span class="platform-name">iPhone</span><span class="platform-state">Download on the App Store</span></a>#endif
|
||||
<a class="platform" href="/downloads/flamenet-messenger.apk" download>
|
||||
<span class="platform-name">Android</span>
|
||||
<span class="platform-state" data-apk-version>Download the app (APK)</span>
|
||||
</a>
|
||||
<a class="platform" href="/get">
|
||||
<span class="platform-name">Other ways</span>
|
||||
<span class="platform-state">Every download, explained</span>
|
||||
</a>
|
||||
</div>
|
||||
<p>Install it, come back to this link, and open it again.</p>
|
||||
<p class="fineprint">
|
||||
Everything about the person is after the <code>#</code> in the address, a
|
||||
part your browser never sends, so this server received a request for
|
||||
<code>/add</code> and nothing else. The page passes it to the app on this
|
||||
phone. It records nothing about you, and it asks search engines not to
|
||||
index it. What the app keeps is written down at
|
||||
<a href="/metadata">what we keep</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="/add.js?v=#(assetVersion)"></script>
|
||||
|
||||
#endexport
|
||||
#endextend
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import Vapor
|
||||
|
||||
/// The two files that let a phone open a contact link (SPEC 8.12) in the
|
||||
/// app: Apple's universal links and Android's verified app links. Both name
|
||||
/// the published app, so they are constants rather than configuration.
|
||||
enum AppLinks {
|
||||
/// Team M5923Y84P5, bundle net.flamenet.messenger; only the link path.
|
||||
static var apple: [String: Any] {
|
||||
[
|
||||
"applinks": [
|
||||
"details": [
|
||||
[
|
||||
"appIDs": ["M5923Y84P5.net.flamenet.messenger"],
|
||||
"components": [["/": "/add*", "comment": "contact links, SPEC 8.12"]],
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
/// io.flamenet.messenger, signed with the Messenger release key. This is
|
||||
/// the certificate's public SHA-256 fingerprint, not key material.
|
||||
static var android: [[String: Any]] {
|
||||
[
|
||||
[
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": [
|
||||
"namespace": "android_app",
|
||||
"package_name": "io.flamenet.messenger",
|
||||
"sha256_cert_fingerprints": [
|
||||
"79:1F:C8:26:D5:72:A7:0A:BF:AF:7D:B7:FA:77:84:DA:D0:F8:6F:20:4B:B3:13:F0:D7:5E:E6:76:DF:8D:3A:1D"
|
||||
],
|
||||
],
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
/// Served as JSON with no redirect, which both platforms require.
|
||||
static func json(_ object: Any) throws -> Response {
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
|
||||
let response = Response(status: .ok, body: .init(data: data))
|
||||
response.headers.contentType = .json
|
||||
response.headers.replaceOrAdd(name: .cacheControl, value: "public, max-age=3600")
|
||||
return response
|
||||
}
|
||||
}
|
||||
@@ -465,6 +465,25 @@ func register(routes r: Routes, on app: Application) throws {
|
||||
}
|
||||
|
||||
|
||||
// SPEC 8.12: the web form of a contact link. The payload is in the
|
||||
// fragment, which no browser sends, so this is one page for every link;
|
||||
// `add.js` hands the fragment to the app. Not indexed, not cached, and the
|
||||
// address is not passed on to anything the page links to.
|
||||
app.get("add") { req async throws -> Response in
|
||||
var page = AddContext(
|
||||
audience: r.config.audience, iosLive: r.config.iosLive,
|
||||
iosStoreURL: r.config.iosStoreURL)
|
||||
page.canonical = publicOrigin(req) + "/add"
|
||||
page.origin = publicOrigin(req)
|
||||
let view: View = try await req.view.render("add", page)
|
||||
let response = Response(status: .ok, body: .init(buffer: view.data))
|
||||
response.headers.contentType = .html
|
||||
response.headers.replaceOrAdd(name: "X-Robots-Tag", value: "noindex")
|
||||
response.headers.replaceOrAdd(name: .cacheControl, value: "no-store")
|
||||
response.headers.replaceOrAdd(name: "Referrer-Policy", value: "no-referrer")
|
||||
return response
|
||||
}
|
||||
|
||||
app.get("status.json") { req async -> StatusReport in
|
||||
await StatusPage.report(
|
||||
draining: req.application.drain.isDraining,
|
||||
@@ -1137,6 +1156,15 @@ func register(routes r: Routes, on app: Application) throws {
|
||||
}
|
||||
|
||||
// RFC 9116.
|
||||
// App links for contact links (SPEC 8.12): a phone with the app opens
|
||||
// https://<this host>/add#... in the app rather than the browser. The
|
||||
// app's identity, not the operator's, so every relay serves the same two.
|
||||
app.get(".well-known", "apple-app-site-association") { _ -> Response in
|
||||
try AppLinks.json(AppLinks.apple)
|
||||
}
|
||||
app.get(".well-known", "assetlinks.json") { _ -> Response in
|
||||
try AppLinks.json(AppLinks.android)
|
||||
}
|
||||
app.get(".well-known", "security.txt") { req -> Response in
|
||||
// RFC 9116 requires Expires, and a stale one is worse than none: it
|
||||
// tells a researcher the file is abandoned.
|
||||
@@ -2017,6 +2045,23 @@ struct JoinContext: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The contact-link page (SPEC 8.12).
|
||||
struct AddContext: Encodable {
|
||||
var lang: String = "en"
|
||||
var section: String = ""
|
||||
var hasAlternate: Bool = false
|
||||
var chrome: LandingContext.Chrome = LandingContext.Chrome()
|
||||
var canonical: String = ""
|
||||
var origin: String = ""
|
||||
var title: String = "A contact link. Flamenet Messenger"
|
||||
var description: String =
|
||||
"Somebody shared a link to start a conversation with them in Flamenet Messenger."
|
||||
let audience: String
|
||||
let assetVersion: String = AssetVersion.value
|
||||
let iosLive: Bool
|
||||
let iosStoreURL: String
|
||||
}
|
||||
|
||||
struct StatusCheckRow: Encodable {
|
||||
let name: String
|
||||
let detail: String
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import Crypto
|
||||
import Foundation
|
||||
import Testing
|
||||
import VaporTesting
|
||||
|
||||
@testable import FlamenetRelay
|
||||
|
||||
/// SPEC §8.12: the web form of a contact link, `https://<relay>/add#…`, and the
|
||||
/// two files that let a phone open it in the app instead of a browser.
|
||||
@Suite("contact link page and app links", .serialized)
|
||||
struct ContactLinkPageTests {
|
||||
|
||||
private func withRelayApp(_ body: (Application) async throws -> Void) async throws {
|
||||
// Serialised against the other suites that boot a relay this way.
|
||||
// `setenv` is process-global; see ProcessEnvironmentLock.
|
||||
try await ProcessEnvironmentLock.withExclusiveEnvironment {
|
||||
let issuer = Curve25519.Signing.PrivateKey()
|
||||
let dbPath = NSTemporaryDirectory() + "fn-add-\(UUID().uuidString).sqlite"
|
||||
let attachments = NSTemporaryDirectory() + "fn-add-att-\(UUID().uuidString)"
|
||||
let env: [String: String] = [
|
||||
"FN_ISSUER_PUBLIC_KEY": issuer.publicKey.rawRepresentation.base64EncodedString(),
|
||||
"FN_ISSUER_PRIVATE_KEY": issuer.rawRepresentation.base64EncodedString(),
|
||||
"FN_RELAY_AUDIENCE": "test.relay",
|
||||
"FN_DB": dbPath,
|
||||
"FN_ATTACHMENTS_DIR": attachments,
|
||||
"FN_LOG_LEVEL": "critical",
|
||||
]
|
||||
let previous = env.keys.reduce(into: [String: String?]()) {
|
||||
$0[$1] = ProcessInfo.processInfo.environment[$1]
|
||||
}
|
||||
for (k, v) in env { setenv(k, v, 1) }
|
||||
defer {
|
||||
for (k, v) in previous { if let v { setenv(k, v, 1) } else { unsetenv(k) } }
|
||||
try? FileManager.default.removeItem(atPath: dbPath)
|
||||
try? FileManager.default.removeItem(atPath: attachments)
|
||||
}
|
||||
let app = try await Application.make(.testing)
|
||||
do {
|
||||
try await configure(app)
|
||||
try await body(app)
|
||||
} catch {
|
||||
try? await app.asyncShutdown()
|
||||
throw error
|
||||
}
|
||||
try? await app.asyncShutdown()
|
||||
}
|
||||
}
|
||||
|
||||
/// The payload is in the fragment, which a browser never sends, so the page
|
||||
/// is the same for every link and hands the fragment to the app itself.
|
||||
@Test("/add is one page for every link, not indexed, and reads the fragment in the browser")
|
||||
func addPage() async throws {
|
||||
try await withRelayApp { app in
|
||||
try await app.testing().test(.GET, "/add") { res in
|
||||
#expect(res.status == .ok)
|
||||
#expect(res.headers.contentType == .html)
|
||||
#expect(res.headers.first(name: "X-Robots-Tag") == "noindex")
|
||||
#expect(res.headers.first(name: "Referrer-Policy") == "no-referrer")
|
||||
#expect(res.headers.first(name: "Cache-Control")?.contains("no-store") == true)
|
||||
let body = res.body.string
|
||||
#expect(body.contains("src=\"/add.js"), "the page does not load its script")
|
||||
#expect(body.contains("id=\"add-open\""), "no button to open the app")
|
||||
#expect(!body.contains("<script>"), "an inline script, which the CSP refuses")
|
||||
}
|
||||
try await app.testing().test(.GET, "/add.js") { res in
|
||||
#expect(res.status == .ok)
|
||||
let js = res.body.string
|
||||
#expect(js.contains("location.hash"), "the script does not read the fragment")
|
||||
#expect(js.contains("flamenet:"), "the script does not build the app form")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS universal links: team M5923Y84P5, bundle net.flamenet.messenger,
|
||||
/// and only the contact-link path.
|
||||
@Test("apple-app-site-association names the app and only /add")
|
||||
func appleAssociation() async throws {
|
||||
try await withRelayApp { app in
|
||||
try await app.testing().test(.GET, "/.well-known/apple-app-site-association") { res in
|
||||
#expect(res.status == .ok)
|
||||
#expect(res.headers.contentType == .json)
|
||||
let json = try JSONSerialization.jsonObject(with: Data(res.body.string.utf8)) as? [String: Any]
|
||||
let details = (json?["applinks"] as? [String: Any])?["details"] as? [[String: Any]]
|
||||
#expect(details?.count == 1)
|
||||
#expect(details?.first?["appIDs"] as? [String] == ["M5923Y84P5.net.flamenet.messenger"])
|
||||
let components = details?.first?["components"] as? [[String: Any]]
|
||||
#expect(components?.compactMap { $0["/"] as? String } == ["/add*"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Android app links: the package and the release certificate's SHA-256.
|
||||
@Test("assetlinks.json names the package and the release certificate")
|
||||
func assetLinks() async throws {
|
||||
try await withRelayApp { app in
|
||||
try await app.testing().test(.GET, "/.well-known/assetlinks.json") { res in
|
||||
#expect(res.status == .ok)
|
||||
#expect(res.headers.contentType == .json)
|
||||
let rows = try JSONSerialization.jsonObject(with: Data(res.body.string.utf8)) as? [[String: Any]]
|
||||
let target = rows?.first?["target"] as? [String: Any]
|
||||
#expect(rows?.first?["relation"] as? [String] == ["delegate_permission/common.handle_all_urls"])
|
||||
#expect(target?["namespace"] as? String == "android_app")
|
||||
#expect(target?["package_name"] as? String == "io.flamenet.messenger")
|
||||
#expect(
|
||||
target?["sha256_cert_fingerprints"] as? [String] == [
|
||||
"79:1F:C8:26:D5:72:A7:0A:BF:AF:7D:B7:FA:77:84:DA:D0:F8:6F:20:4B:B3:13:F0:D7:5E:E6:76:DF:8D:3A:1D"
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user