Fix relay URL trailing slash issues

This commit tries to replace all usage of `String` to represent relay
URLs and use `RelayURL` which automatically converts strings to a
canonical relay URL format that is more reliable and avoids issues related to
trailing slashes.

Test 1: Main issue fix
-----------------------

PASS

Device: iPhone 15 Simulator
iOS: 17.4
Damus: This commit
Steps:
1. Delete all connected relays
2. Add `wss://relay.damus.io/` (with the trailing slash) to the relay list
3. Try to post. Post should succeed. PASS
4. Try removing this newly added relay. Relay should be removed successfully. PASS

Test 2: Persistent relay list after upgrade
--------------------------------------------

PASS

Device: iPhone 15 Simulator
iOS: 17.4
Damus: 1.8 (1) `247f313b` + This commit
Steps:
1. Downgrade to old version
2. Add some relays to the list, some without a trailing slash, some with
3. Upgrade to this commit
4. All relays added in step 2 should still be there, and ones with a trailing slash should have been corrected to remove the trailing slash

Test 3: Miscellaneous regression tests
--------------------------------------

Device: iPhone 15 Simulator
iOS: 17.4
Damus: This commit
Coverage:
1. Posting works
2. Search works
3. Relay connection status works
4. Adding relays work
5. Removing relays work
6. Adding relay with trailing slashes works (it fixes itself to remove the trailing slash)
7. Adding relays with different paths works (e.g. wss://yabu.me/v1 and wss://yabu.me/v2)
8. Adding duplicate relay (but with trailing slash) gets rejected as expected
9. Relay details page works. All items on that view loads correctly
10. Relay logs work
11. Getting follower counts and seeing follow lists on profiles still work
12. Relay list changes persist after app restart
13. Notifications view still work
14. Copying the user's pubkey and profile link works
15. Share note + copy link button still works
16. Connecting NWC wallet works
17. One-tap zaps work
18. Onboarding works
19. Unit tests all passing

Closes: https://github.com/damus-io/damus/issues/2072
Changelog-Fixed: Fix bug that would cause connection issues with relays defined with a trailing slash URL, and an inability to delete them.
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
Daniel D’Aquino
2024-03-22 16:55:35 +00:00
committed by William Casarin
parent b18941383f
commit e951370a76
46 changed files with 386 additions and 311 deletions

View File

@@ -8,7 +8,7 @@
import Foundation
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: Relay) -> NostrEvent? {
let tags: [[String]] = [["relay", relay.descriptor.url.id],["challenge", challenge_string]]
let tags: [[String]] = [["relay", relay.descriptor.url.absoluteString],["challenge", challenge_string]]
let event = NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 22242, tags: tags)
return event
}

View File

@@ -10,9 +10,9 @@ import Foundation
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
var tags = zap_target_to_tags(target)
var relay_tag = ["relays"]
relay_tag.append(contentsOf: relays.map { $0.url.id })
relay_tag.append(contentsOf: relays.map { $0.url.absoluteString })
tags.append(relay_tag)
var kp = keypair
let now = UInt32(Date().timeIntervalSince1970)
@@ -79,8 +79,8 @@ func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair,
func make_first_contact_event(keypair: Keypair) -> NostrEvent? {
let bootstrap_relays = load_bootstrap_relays(pubkey: keypair.pubkey)
let rw_relay_info = RelayInfo(read: true, write: true)
var relays: [String: RelayInfo] = [:]
var relays: [RelayURL: RelayInfo] = [:]
for relay in bootstrap_relays {
relays[relay] = rw_relay_info
}

View File

@@ -118,9 +118,9 @@ class Relay: Identifiable {
var is_broken: Bool {
return (flags & RelayFlags.broken.rawValue) == RelayFlags.broken.rawValue
}
var id: String {
return get_relay_id(descriptor.url)
var id: RelayURL {
return descriptor.url
}
}
@@ -128,15 +128,3 @@ class Relay: Identifiable {
enum RelayError: Error {
case RelayAlreadyExists
}
func get_relay_id(_ url: RelayURL) -> String {
let trimTrailingSlashes: (String) -> String = { url in
var trimmedUrl = url
while trimmedUrl.hasSuffix("/") {
trimmedUrl.removeLast()
}
return trimmedUrl
}
return trimTrailingSlashes(url.url.absoluteString)
}

View File

@@ -21,19 +21,19 @@ final class RelayConnection: ObservableObject {
private(set) var last_connection_attempt: TimeInterval = 0
private(set) var last_pong: Date? = nil
private(set) var backoff: TimeInterval = 1.0
private lazy var socket = WebSocket(url.url)
private lazy var socket = WebSocket(relay_url.url)
private var subscriptionToken: AnyCancellable?
private var handleEvent: (NostrConnectionEvent) -> ()
private var processEvent: (WebSocketEvent) -> ()
private let url: RelayURL
private let relay_url: RelayURL
var log: RelayLog?
init(url: RelayURL,
handleEvent: @escaping (NostrConnectionEvent) -> (),
processEvent: @escaping (WebSocketEvent) -> ())
{
self.url = url
self.relay_url = url
self.handleEvent = handleEvent
self.processEvent = processEvent
}
@@ -48,7 +48,7 @@ final class RelayConnection: ObservableObject {
self.last_pong = .now
self.log?.add("Successful ping")
} else {
print("pong failed, reconnecting \(self.url.id)")
print("pong failed, reconnecting \(self.relay_url.id)")
self.isConnected = false
self.isConnecting = false
self.reconnect_with_backoff()
@@ -126,7 +126,7 @@ final class RelayConnection: ObservableObject {
self.receive(message: message)
case .disconnected(let closeCode, let reason):
if closeCode != .normalClosure {
print("⚠️ Warning: RelayConnection (\(self.url)) closed with code \(closeCode), reason: \(String(describing: reason))")
print("⚠️ Warning: RelayConnection (\(self.relay_url)) closed with code \(closeCode), reason: \(String(describing: reason))")
}
DispatchQueue.main.async {
self.isConnected = false
@@ -134,7 +134,7 @@ final class RelayConnection: ObservableObject {
self.reconnect()
}
case .error(let error):
print("⚠️ Warning: RelayConnection (\(self.url)) error: \(error)")
print("⚠️ Warning: RelayConnection (\(self.relay_url)) error: \(error)")
let nserr = error as NSError
if nserr.domain == NSPOSIXErrorDomain && nserr.code == 57 {
// ignore socket not connected?

View File

@@ -13,7 +13,7 @@ import UIKit
/// will have information to help developers debug issues.
final class RelayLog: ObservableObject {
private static let line_limit = 250
private let relay_url: URL?
private let relay_url: RelayURL?
private lazy var formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
@@ -29,9 +29,9 @@ final class RelayLog: ObservableObject {
/// - Parameter relay_url: the relay url the log represents. Pass nil for the url to create
/// a RelayLog that does nothing. This is required to allow RelayLog to be used as a StateObject,
/// because they cannot be Optional.
init(_ relay_url: URL? = nil) {
init(_ relay_url: RelayURL? = nil) {
self.relay_url = relay_url
setUp()
}

View File

@@ -10,17 +10,17 @@ import Network
struct RelayHandler {
let sub_id: String
let callback: (String, NostrConnectionEvent) -> ()
let callback: (RelayURL, NostrConnectionEvent) -> ()
}
struct QueuedRequest {
let req: NostrRequestType
let relay: String
let relay: RelayURL
let skip_ephemeral: Bool
}
struct SeenEvent: Hashable {
let relay_id: String
let relay_id: RelayURL
let evid: NoteId
}
@@ -29,7 +29,7 @@ class RelayPool {
var handlers: [RelayHandler] = []
var request_queue: [QueuedRequest] = []
var seen: Set<SeenEvent> = Set()
var counts: [String: UInt64] = [:]
var counts: [RelayURL: UInt64] = [:]
var ndb: Ndb
var keypair: Keypair?
var message_received_function: (((String, RelayDescriptor)) -> Void)?
@@ -93,8 +93,8 @@ class RelayPool {
relay.connection.ping()
}
}
func register_handler(sub_id: String, handler: @escaping (String, NostrConnectionEvent) -> ()) {
func register_handler(sub_id: String, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) {
for handler in handlers {
// don't add duplicate handlers
if handler.sub_id == sub_id {
@@ -104,10 +104,10 @@ class RelayPool {
self.handlers.append(RelayHandler(sub_id: sub_id, callback: handler))
print("registering \(sub_id) handler, current: \(self.handlers.count)")
}
func remove_relay(_ relay_id: String) {
func remove_relay(_ relay_id: RelayURL) {
var i: Int = 0
self.disconnect(to: [relay_id])
for relay in relays {
@@ -120,14 +120,13 @@ class RelayPool {
i += 1
}
}
func add_relay(_ desc: RelayDescriptor) throws {
let url = desc.url
let relay_id = get_relay_id(url)
let relay_id = desc.url
if get_relay(relay_id) != nil {
throw RelayError.RelayAlreadyExists
}
let conn = RelayConnection(url: url, handleEvent: { event in
let conn = RelayConnection(url: desc.url, handleEvent: { event in
self.handle_event(relay_id: relay_id, event: event)
}, processEvent: { wsev in
guard case .message(let msg) = wsev,
@@ -140,11 +139,11 @@ class RelayPool {
let relay = Relay(descriptor: desc, connection: conn)
self.relays.append(relay)
}
func setLog(_ log: RelayLog, for relay_id: String) {
func setLog(_ log: RelayLog, for relay_id: RelayURL) {
// add the current network state to the log
log.add("Network state: \(network_monitor.currentPath.status)")
get_relay(relay_id)?.connection.log = log
}
@@ -154,9 +153,9 @@ class RelayPool {
let c = relay.connection
let is_connecting = c.isConnecting
if is_connecting && (Date.now.timeIntervalSince1970 - c.last_connection_attempt) > 5 {
print("stale connection detected (\(relay.descriptor.url.url.absoluteString)). retrying...")
print("stale connection detected (\(relay.descriptor.url.absoluteString)). retrying...")
relay.connection.reconnect()
} else if relay.is_broken || is_connecting || c.isConnected {
continue
@@ -166,8 +165,8 @@ class RelayPool {
}
}
func reconnect(to: [String]? = nil) {
func reconnect(to: [RelayURL]? = nil) {
let relays = to.map{ get_relays($0) } ?? self.relays
for relay in relays {
// don't try to reconnect to broken relays
@@ -175,38 +174,38 @@ class RelayPool {
}
}
func connect(to: [String]? = nil) {
func connect(to: [RelayURL]? = nil) {
let relays = to.map{ get_relays($0) } ?? self.relays
for relay in relays {
relay.connection.connect()
}
}
func disconnect(to: [String]? = nil) {
func disconnect(to: [RelayURL]? = nil) {
let relays = to.map{ get_relays($0) } ?? self.relays
for relay in relays {
relay.connection.disconnect()
}
}
func unsubscribe(sub_id: String, to: [String]? = nil) {
func unsubscribe(sub_id: String, to: [RelayURL]? = nil) {
if to == nil {
self.remove_handler(sub_id: sub_id)
}
self.send(.unsubscribe(sub_id), to: to)
}
func subscribe(sub_id: String, filters: [NostrFilter], handler: @escaping (String, NostrConnectionEvent) -> (), to: [String]? = nil) {
func subscribe(sub_id: String, filters: [NostrFilter], handler: @escaping (RelayURL, NostrConnectionEvent) -> (), to: [RelayURL]? = nil) {
register_handler(sub_id: sub_id, handler: handler)
send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
}
func subscribe_to(sub_id: String, filters: [NostrFilter], to: [String]?, handler: @escaping (String, NostrConnectionEvent) -> ()) {
func subscribe_to(sub_id: String, filters: [NostrFilter], to: [RelayURL]?, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) {
register_handler(sub_id: sub_id, handler: handler)
send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
}
func count_queued(relay: String) -> Int {
func count_queued(relay: RelayURL) -> Int {
var c = 0
for request in request_queue {
if request.relay == relay {
@@ -216,8 +215,8 @@ class RelayPool {
return c
}
func queue_req(r: NostrRequestType, relay: String, skip_ephemeral: Bool) {
func queue_req(r: NostrRequestType, relay: RelayURL, skip_ephemeral: Bool) {
let count = count_queued(relay: relay)
guard count <= 10 else {
print("can't queue, too many queued events for \(relay)")
@@ -228,9 +227,9 @@ class RelayPool {
request_queue.append(QueuedRequest(req: r, relay: relay, skip_ephemeral: skip_ephemeral))
}
func send_raw(_ req: NostrRequestType, to: [String]? = nil, skip_ephemeral: Bool = true) {
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) {
let relays = to.map{ get_relays($0) } ?? self.relays
// send to local relay (nostrdb)
switch req {
case .typical(let r):
@@ -264,21 +263,21 @@ class RelayPool {
})
}
}
func send(_ req: NostrRequest, to: [String]? = nil, skip_ephemeral: Bool = true) {
func send(_ req: NostrRequest, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) {
send_raw(.typical(req), to: to, skip_ephemeral: skip_ephemeral)
}
func get_relays(_ ids: [String]) -> [Relay] {
func get_relays(_ ids: [RelayURL]) -> [Relay] {
// don't include ephemeral relays in the default list to query
relays.filter { ids.contains($0.id) }
}
func get_relay(_ id: String) -> Relay? {
func get_relay(_ id: RelayURL) -> Relay? {
relays.first(where: { $0.id == id })
}
func run_queue(_ relay_id: String) {
func run_queue(_ relay_id: RelayURL) {
self.request_queue = request_queue.reduce(into: Array<QueuedRequest>()) { (q, req) in
guard req.relay == relay_id else {
q.append(req)
@@ -289,8 +288,8 @@ class RelayPool {
self.send_raw(req.req, to: [relay_id], skip_ephemeral: false)
}
}
func record_seen(relay_id: String, event: NostrConnectionEvent) {
func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) {
if case .nostr_event(let ev) = event {
if case .event(_, let nev) = ev {
let k = SeenEvent(relay_id: relay_id, evid: nev.id)
@@ -305,10 +304,10 @@ class RelayPool {
}
}
}
func handle_event(relay_id: String, event: NostrConnectionEvent) {
func handle_event(relay_id: RelayURL, event: NostrConnectionEvent) {
record_seen(relay_id: relay_id, event: event)
// run req queue when we reconnect
if case .ws_event(let ws) = event {
if case .connected = ws {
@@ -349,10 +348,7 @@ class RelayPool {
}
}
func add_rw_relay(_ pool: RelayPool, _ url: String) {
guard let url = RelayURL(url) else {
return
}
func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) {
try? pool.add_relay(RelayDescriptor(url: url, info: .rw))
}

View File

@@ -7,18 +7,31 @@
import Foundation
public struct RelayURL: Hashable, Equatable, Codable, CodingKeyRepresentable {
public struct RelayURL: Hashable, Equatable, Codable, CodingKeyRepresentable, Identifiable, Comparable, CustomStringConvertible {
private(set) var url: URL
var id: String {
public var id: URL {
return url
}
public var description: String {
return self.absoluteString
}
public var absoluteString: String {
return url.absoluteString
}
init?(_ str: String) {
guard let url = URL(string: str) else {
var trimmed_url_str = str
while trimmed_url_str.hasSuffix("/") {
trimmed_url_str.removeLast()
}
guard let url = URL(string: trimmed_url_str) else {
return nil
}
guard let scheme = url.scheme else {
return nil
}
@@ -67,7 +80,12 @@ public struct RelayURL: Hashable, Equatable, Codable, CodingKeyRepresentable {
public func hash(into hasher: inout Hasher) {
hasher.combine(self.url)
}
// MARK: - Comparable
public static func < (lhs: RelayURL, rhs: RelayURL) -> Bool {
return lhs.url.absoluteString < rhs.url.absoluteString
}
}
private struct StringKey: CodingKey {