feat(mobile/ios): replay lease subscriptions in notification extension

Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
This commit is contained in:
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp
2026-07-27 19:52:05 -07:00
parent 4e98d17817
commit a7e2283537
3 changed files with 526 additions and 85 deletions
@@ -0,0 +1,199 @@
import Foundation
public enum PushLeaseError: Error, Equatable {
case unsupportedAuthority(String)
case acceptedAuthorityMissingSubscriptions
case emptySubscriptions
}
public struct PushLeaseSnapshot: Codable, Equatable, Sendable {
public let communities: [PushLeaseCommunity]
public init(communities: [PushLeaseCommunity]) {
self.communities = communities
}
}
public struct PushLeaseCommunity: Codable, Equatable, Sendable {
public let id: String
public let name: String
public let relayUrl: String
public let pubkey: String?
public let pushSubscriptionState: PushLeaseSubscriptionState
public init(
id: String,
name: String,
relayUrl: String,
pubkey: String?,
pushSubscriptionState: PushLeaseSubscriptionState
) {
self.id = id
self.name = name
self.relayUrl = relayUrl
self.pubkey = pubkey
self.pushSubscriptionState = pushSubscriptionState
}
}
public struct PushLeaseSubscriptionState: Codable, Equatable, Sendable {
public enum Authority: String, Codable, Sendable {
case desired
case accepted
}
public let authority: String
public let desired: [PushLeaseSubscription]
public let accepted: [PushLeaseSubscription]?
public init(
authority: String,
desired: [PushLeaseSubscription],
accepted: [PushLeaseSubscription]? = nil
) {
self.authority = authority
self.desired = desired
self.accepted = accepted
}
/// Workstream A has no lease publisher, so `desired` is the only valid
/// authority today. A later publisher must persist the observed accepted
/// lease and switch this field explicitly. This keeps relay rejection,
/// clamping, or expiry visible rather than assuming acceptance.
public func authoritativeSubscriptions() throws -> [PushLeaseSubscription] {
let subscriptions: [PushLeaseSubscription]
switch authority {
case Authority.desired.rawValue:
subscriptions = desired
case Authority.accepted.rawValue:
guard let accepted else {
throw PushLeaseError.acceptedAuthorityMissingSubscriptions
}
subscriptions = accepted
default:
throw PushLeaseError.unsupportedAuthority(authority)
}
guard !subscriptions.isEmpty else {
throw PushLeaseError.emptySubscriptions
}
return subscriptions
}
}
public struct PushLeaseSubscription: Codable, Equatable, Sendable {
public let filter: PushLeaseFilter
public let notificationClass: String
public let ignore: [PushLeaseFilter]
public let suppress: PushLeaseSuppression?
enum CodingKeys: String, CodingKey {
case filter
case notificationClass = "class"
case ignore
case suppress
}
public init(
filter: PushLeaseFilter,
notificationClass: String,
ignore: [PushLeaseFilter] = [],
suppress: PushLeaseSuppression? = nil
) {
self.filter = filter
self.notificationClass = notificationClass
self.ignore = ignore
self.suppress = suppress
}
}
public struct PushLeaseSuppression: Codable, Equatable, Sendable {
public let pTagsMax: Int
enum CodingKeys: String, CodingKey {
case pTagsMax = "p_tags_max"
}
public init(pTagsMax: Int) {
self.pTagsMax = pTagsMax
}
}
public struct PushLeaseFilter: Codable, Equatable, Sendable {
public let kinds: [Int]
public let authors: [String]?
public let pTags: [String]?
public let hTags: [String]?
public let eTags: [String]?
enum CodingKeys: String, CodingKey {
case kinds
case authors
case pTags = "#p"
case hTags = "#h"
case eTags = "#e"
}
public init(
kinds: [Int],
authors: [String]? = nil,
pTags: [String]? = nil,
hTags: [String]? = nil,
eTags: [String]? = nil
) {
self.kinds = kinds
self.authors = authors
self.pTags = pTags
self.hTags = hTags
self.eTags = eTags
}
public func queryFilter(since: Int?, limit: Int) -> [String: Any] {
var filter: [String: Any] = ["kinds": kinds, "limit": limit]
if let authors { filter["authors"] = authors }
if let pTags { filter["#p"] = pTags }
if let hTags { filter["#h"] = hTags }
if let eTags { filter["#e"] = eTags }
if let since { filter["since"] = since }
return filter
}
public func matches(_ event: VerifiedNostrEvent) -> Bool {
guard kinds.contains(event.kind) else { return false }
if let authors, !authors.contains(event.pubkey.lowercased()) { return false }
if let pTags, !event.hasAnyTag(named: "p", values: pTags) { return false }
if let hTags, !event.hasAnyTag(named: "h", values: hTags) { return false }
if let eTags, !event.hasAnyTag(named: "e", values: eTags) { return false }
return true
}
}
public enum PushLeaseMatcher {
public static func matches(
event: VerifiedNostrEvent,
subscription: PushLeaseSubscription
) -> Bool {
guard subscription.filter.matches(event) else { return false }
if subscription.ignore.contains(where: { $0.matches(event) }) { return false }
if let maximum = subscription.suppress?.pTagsMax,
event.tagCount(named: "p") > maximum
{
return false
}
return true
}
}
extension VerifiedNostrEvent {
public func tagCount(named name: String) -> Int {
tags.reduce(into: 0) { count, tag in
if tag.count >= 2 && tag[0] == name { count += 1 }
}
}
public func hasAnyTag(named name: String, values: [String]) -> Bool {
let expected = Set(values.map { $0.lowercased() })
return tags.contains { tag in
tag.count >= 2 && tag[0] == name && expected.contains(tag[1].lowercased())
}
}
}
@@ -0,0 +1,109 @@
import XCTest
@testable import BuzzPushKit
final class PushLeaseTests: XCTestCase {
private let mine = String(repeating: "a", count: 64)
private let other = String(repeating: "b", count: 64)
func testDesiredAuthorityIsExplicitAndAcceptedAuthorityRequiresState() throws {
let subscription = PushLeaseSubscription(
filter: PushLeaseFilter(kinds: [9], pTags: [mine]),
notificationClass: "default"
)
XCTAssertEqual(
try PushLeaseSubscriptionState(
authority: "desired",
desired: [subscription]
).authoritativeSubscriptions(),
[subscription]
)
XCTAssertThrowsError(
try PushLeaseSubscriptionState(
authority: "accepted",
desired: [subscription]
).authoritativeSubscriptions()
) { error in
XCTAssertEqual(error as? PushLeaseError, .acceptedAuthorityMissingSubscriptions)
}
}
func testFilterBuildsQueryFromLeaseWithoutHardcodedKinds() {
let filter = PushLeaseFilter(
kinds: [7, 1059],
authors: [other],
pTags: [mine],
hTags: ["channel"],
eTags: [String(repeating: "c", count: 64)]
)
let query = filter.queryFilter(since: 1_000, limit: 10)
XCTAssertEqual(query["kinds"] as? [Int], [7, 1059])
XCTAssertEqual(query["authors"] as? [String], [other])
XCTAssertEqual(query["#p"] as? [String], [mine])
XCTAssertEqual(query["#h"] as? [String], ["channel"])
XCTAssertEqual(query["since"] as? Int, 1_000)
}
func testPushEligibleKindAbsentFromOldConstantMatchesLease() {
let event = makeEvent(kind: 1059, tags: [["p", mine]])
let subscription = PushLeaseSubscription(
filter: PushLeaseFilter(kinds: [1059], pTags: [mine]),
notificationClass: "default"
)
XCTAssertTrue(PushLeaseMatcher.matches(event: event, subscription: subscription))
}
func testIgnoreAndHellthreadSuppressionRejectCandidates() {
let ignored = makeEvent(kind: 9, pubkey: other, tags: [["p", mine]])
let ignoreSubscription = PushLeaseSubscription(
filter: PushLeaseFilter(kinds: [9], pTags: [mine]),
notificationClass: "default",
ignore: [PushLeaseFilter(kinds: [9], authors: [other])]
)
XCTAssertFalse(
PushLeaseMatcher.matches(event: ignored, subscription: ignoreSubscription)
)
let hellthread = makeEvent(
kind: 9,
tags: (0..<21).map { ["p", String(format: "%064x", $0)] }
)
let suppressed = PushLeaseSubscription(
filter: PushLeaseFilter(kinds: [9], authors: [other]),
notificationClass: "default",
suppress: PushLeaseSuppression(pTagsMax: 20)
)
XCTAssertFalse(PushLeaseMatcher.matches(event: hellthread, subscription: suppressed))
}
func testDecodesSnapshotContractFromDartShape() throws {
let json = """
{"communities":[{"id":"origin","name":"Team","relayUrl":"https://relay.example.com","pubkey":"\(mine)","pushSubscriptionState":{"authority":"desired","desired":[{"filter":{"kinds":[9],"#p":["\(mine)"]},"class":"default","ignore":[{"kinds":[9],"authors":["\(mine)"]}],"suppress":{"p_tags_max":20}}]}}]}
"""
let snapshot = try JSONDecoder().decode(PushLeaseSnapshot.self, from: Data(json.utf8))
XCTAssertEqual(snapshot.communities.count, 1)
XCTAssertEqual(
try snapshot.communities[0].pushSubscriptionState.authoritativeSubscriptions().count,
1
)
}
private func makeEvent(
kind: Int,
pubkey: String? = nil,
tags: [[String]] = []
) -> VerifiedNostrEvent {
VerifiedNostrEvent(
id: String(repeating: "d", count: 64),
pubkey: pubkey ?? other,
createdAt: 1_000,
kind: kind,
tags: tags,
content: "message",
sig: String(repeating: "e", count: 128)
)
}
}
@@ -19,26 +19,30 @@ final class NotificationService: UNNotificationServiceExtension {
}
bestAttemptContent = content
resolver.resolve { [weak self] resolution in
resolver.resolve { [weak self] result in
guard let self else { return }
if let resolution {
switch result {
case .notification(let resolution):
content.title = resolution.title
content.body = resolution.body
if let subtitle = resolution.subtitle {
content.subtitle = subtitle
}
content.subtitle = resolution.subtitle ?? ""
if let threadIdentifier = resolution.threadIdentifier {
content.threadIdentifier = threadIdentifier
}
case .diagnostic(let message):
content.title = "Buzz notification needs attention"
content.body = message
content.subtitle = ""
content.threadIdentifier = "buzz.push.diagnostic"
case .none:
break
}
self.finish(content)
}
}
override func serviceExtensionTimeWillExpire() {
if let bestAttemptContent {
finish(bestAttemptContent)
}
if let bestAttemptContent { finish(bestAttemptContent) }
}
private func finish(_ content: UNNotificationContent) {
@@ -48,129 +52,254 @@ final class NotificationService: UNNotificationServiceExtension {
}
}
struct BuzzPushResolution: Decodable {
struct BuzzPushResolution {
let title: String
let body: String
let subtitle: String?
let threadIdentifier: String?
}
enum BuzzPushResolutionResult {
case notification(BuzzPushResolution)
case diagnostic(String)
case none
}
protocol BuzzPushNotificationResolving {
func resolve(completion: @escaping (BuzzPushResolution?) -> Void)
func resolve(completion: @escaping (BuzzPushResolutionResult) -> Void)
}
final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
private struct Candidate {
let resolution: BuzzPushResolution
let event: VerifiedNostrEvent
let community: PushLeaseCommunity
}
private let session: URLSession
private let appGroupIdentifier: String?
private let keychainAccessGroup: String?
private let defaults: UserDefaults?
private let fileManager: FileManager
init(
session: URLSession = .shared,
appGroupIdentifier: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String,
keychainAccessGroup: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
appGroupIdentifier: String? = Bundle.main.object(
forInfoDictionaryKey: "BuzzAppGroupIdentifier"
) as? String,
keychainAccessGroup: String? = Bundle.main.object(
forInfoDictionaryKey: "BuzzKeychainAccessGroup"
) as? String,
fileManager: FileManager = .default
) {
self.session = session
self.appGroupIdentifier = appGroupIdentifier
self.keychainAccessGroup = keychainAccessGroup
defaults = appGroupIdentifier.flatMap(UserDefaults.init(suiteName:))
self.fileManager = fileManager
}
func resolve(completion: @escaping (BuzzPushResolution?) -> Void) {
let loadedCommunities = loadCommunities()
func resolve(completion: @escaping (BuzzPushResolutionResult) -> Void) {
let loadedCommunities: [PushLeaseCommunity]
do {
loadedCommunities = try loadCommunities()
} catch {
completion(.diagnostic("Open Buzz to refresh notification subscriptions."))
return
}
removeStaleWatermarks(activeCommunityIDs: Set(loadedCommunities.map(\.id)))
let communities = loadedCommunities.filter {
$0.pubkey?.isEmpty == false && loadPrivateKey(communityID: $0.id) != nil
}
guard !communities.isEmpty else { completion(nil); return }
guard !communities.isEmpty else {
completion(.diagnostic("Open Buzz to restore notification credentials."))
return
}
let group = DispatchGroup()
let lock = NSLock()
var candidates: [(BuzzPushResolution, VerifiedNostrEvent, BuzzPushCommunity)] = []
var candidates: [Candidate] = []
var diagnostics: [String] = []
for community in communities {
group.enter()
query(community) { candidate in
if let candidate {
lock.lock(); candidates.append((candidate.0, candidate.1, community)); lock.unlock()
query(community) { result in
lock.lock()
switch result {
case .candidate(let candidate): candidates.append(candidate)
case .diagnostic(let diagnostic): diagnostics.append(diagnostic)
case .none: break
}
lock.unlock()
group.leave()
}
}
group.notify(queue: .global(qos: .userInitiated)) { [weak self] in
guard let self else { return }
let newest = candidates.max {
$0.1.createdAt == $1.1.createdAt ? $0.1.id > $1.1.id : $0.1.createdAt < $1.1.createdAt
let sorted = candidates.sorted { lhs, rhs in
if lhs.event.createdAt != rhs.event.createdAt {
return lhs.event.createdAt > rhs.event.createdAt
}
if lhs.event.id != rhs.event.id { return lhs.event.id < rhs.event.id }
return lhs.community.id < rhs.community.id
}
guard let winner = sorted.first else {
completion(diagnostics.sorted().first.map(BuzzPushResolutionResult.diagnostic) ?? .none)
return
}
for candidate in candidates {
self.defaults?.set(
PushWatermark.persistedTimestamp(eventTimestamp: candidate.1.createdAt),
forKey: PushWatermark.key(communityID: candidate.2.id)
PushWatermark.persistedTimestamp(eventTimestamp: candidate.event.createdAt),
forKey: PushWatermark.key(communityID: candidate.community.id)
)
}
completion(newest?.0)
completion(.notification(winner.resolution))
}
}
private enum QueryResult {
case candidate(Candidate)
case diagnostic(String)
case none
}
private func query(
_ community: BuzzPushCommunity,
completion: @escaping ((BuzzPushResolution, VerifiedNostrEvent)?) -> Void
_ community: PushLeaseCommunity,
completion: @escaping (QueryResult) -> Void
) {
guard let privateKey = loadPrivateKey(communityID: community.id), let pubkey = community.pubkey else {
completion(nil); return
guard let privateKey = loadPrivateKey(communityID: community.id) else {
completion(.diagnostic("Open Buzz to restore notification credentials."))
return
}
let subscriptions: [PushLeaseSubscription]
do {
subscriptions = try community.pushSubscriptionState.authoritativeSubscriptions()
} catch {
completion(.diagnostic("Open Buzz to refresh notification subscriptions."))
return
}
var filter: [String: Any] = ["kinds": [9, 40002, 45001, 45003], "#p": [pubkey], "limit": 10]
let watermarkKey = PushWatermark.key(communityID: community.id)
let storedWatermark = defaults?.integer(forKey: watermarkKey) ?? 0
let watermark = PushWatermark.queryTimestamp(storedWatermark: storedWatermark)
if watermark != storedWatermark { defaults?.set(watermark, forKey: watermarkKey) }
if let since = PushWatermark.querySince(watermark: watermark) { filter["since"] = since }
guard let body = try? JSONSerialization.data(withJSONObject: [filter]) else { completion(nil); return }
let url = URL(string: "/query", relativeTo: community.relayURL)!
let since = PushWatermark.querySince(watermark: watermark)
let filters = subscriptions.map { $0.filter.queryFilter(since: since, limit: 10) }
guard let body = try? JSONSerialization.data(withJSONObject: filters) else {
completion(.diagnostic("Buzz notification subscriptions are invalid."))
return
}
guard let relayURL = community.relayURL,
let url = URL(string: "/query", relativeTo: relayURL)
else {
completion(.diagnostic("Buzz notification relay URL is invalid."))
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"; request.httpBody = body; request.timeoutInterval = 8
request.httpMethod = "POST"
request.httpBody = body
request.timeoutInterval = 8
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
guard let auth = try? NostrHTTPAuth.authorizationHeader(
url: url, method: "POST", body: body, privateKeyHex: privateKey
) else { completion(nil); return }
guard
let auth = try? NostrHTTPAuth.authorizationHeader(
url: url,
method: "POST",
body: body,
privateKeyHex: privateKey
)
else {
completion(.diagnostic("Buzz could not authenticate notification catch-up."))
return
}
request.setValue(auth, forHTTPHeaderField: "Authorization")
session.dataTask(with: request) { data, response, _ in
guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode),
let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
else { completion(nil); return }
completion(Self.decodeResolution(
events: events.filter {
$0.hasValidIDAndSignature() && PushWatermark.isAcceptable(eventTimestamp: $0.createdAt)
},
community: community
))
guard let response = response as? HTTPURLResponse,
(200..<300).contains(response.statusCode),
let data,
let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
else {
completion(.none)
return
}
completion(
Self.decodeResolution(
events: events,
community: community,
subscriptions: subscriptions
)
)
}.resume()
}
private static func decodeResolution(
events: [VerifiedNostrEvent], community: BuzzPushCommunity
) -> (BuzzPushResolution, VerifiedNostrEvent)? {
guard let mine = community.pubkey?.lowercased() else { return nil }
let event = events.filter {
$0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind)
events: [VerifiedNostrEvent],
community: PushLeaseCommunity,
subscriptions: [PushLeaseSubscription]
) -> QueryResult {
let matching = events.filter { event in
event.hasValidIDAndSignature()
&& PushWatermark.isAcceptable(eventTimestamp: event.createdAt)
&& subscriptions.contains(where: {
PushLeaseMatcher.matches(event: event, subscription: $0)
})
}.sorted {
$0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt
}.first
guard let event else { return nil }
let body = previewBody(event.content)
guard !body.isEmpty else { return nil }
let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1]
return (BuzzPushResolution(
title: shortPubkey(event.pubkey), body: body, subtitle: community.name,
threadIdentifier: channel ?? community.id
), event)
}
for event in matching {
let resolution: BuzzPushResolution
if event.kind == 9 {
let body = previewBody(event.content)
guard !body.isEmpty else { continue }
let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1]
resolution = BuzzPushResolution(
title: shortPubkey(event.pubkey),
body: body,
subtitle: community.name,
threadIdentifier: channel ?? community.id
)
} else {
// Wake-only kinds are fetched from the authoritative lease, but this
// extension cannot render them yet.
resolution = BuzzPushResolution(
title: "Buzz notification needs attention",
body: "Buzz received a kind \(event.kind) wake that this app version cannot display.",
subtitle: community.name,
threadIdentifier: "buzz.push.unsupported-kind.\(event.kind)"
)
}
return .candidate(Candidate(resolution: resolution, event: event, community: community))
}
return .none
}
private static func previewBody(_ content: String) -> String {
var result = content.replacingOccurrences(of: #"```[\s\S]*?```"#, with: "[code]", options: .regularExpression)
result = result.replacingOccurrences(of: #"`([^`]*)`"#, with: "$1", options: .regularExpression)
result = result.replacingOccurrences(of: #"!?\[([^\]]*)\]\([^)]*\)"#, with: "$1", options: .regularExpression)
result = result.replacingOccurrences(of: #"https?://\S+"#, with: "[link]", options: .regularExpression)
result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines)
return result.count > 180 ? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" : result
var result = content.replacingOccurrences(
of: #"```[\s\S]*?```"#,
with: "[code]",
options: .regularExpression
)
result = result.replacingOccurrences(
of: #"`([^`]*)`"#,
with: "$1",
options: .regularExpression
)
result = result.replacingOccurrences(
of: #"!?\[([^\]]*)\]\([^)]*\)"#,
with: "$1",
options: .regularExpression
)
result = result.replacingOccurrences(
of: #"https?://\S+"#,
with: "[link]",
options: .regularExpression
)
result = result.replacingOccurrences(
of: #"\s+"#,
with: " ",
options: .regularExpression
).trimmingCharacters(in: .whitespacesAndNewlines)
return result.count > 180
? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…"
: result
}
private static func shortPubkey(_ pubkey: String) -> String {
@@ -195,34 +324,38 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
if let keychainAccessGroup, !keychainAccessGroup.isEmpty { query[kSecAttrAccessGroup as String] = keychainAccessGroup }
if let keychainAccessGroup, !keychainAccessGroup.isEmpty {
query[kSecAttrAccessGroup as String] = keychainAccessGroup
}
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
let data = item as? Data
else { return nil }
return String(data: data, encoding: .utf8)
}
private func loadCommunities() -> [BuzzPushCommunity] {
private func loadCommunities() throws -> [PushLeaseCommunity] {
guard let appGroupIdentifier,
let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier),
let data = try? Data(contentsOf: container.appendingPathComponent("push-communities.json")),
let decoded = try? JSONDecoder().decode(BuzzPushSnapshot.self, from: data)
else { return [] }
return decoded.communities
let container = fileManager.containerURL(
forSecurityApplicationGroupIdentifier: appGroupIdentifier
)
else {
throw CocoaError(.fileNoSuchFile)
}
let snapshotURL = container.appendingPathComponent("push-communities.json")
let data = try Data(contentsOf: snapshotURL)
return try JSONDecoder().decode(PushLeaseSnapshot.self, from: data).communities
}
}
struct BuzzPushSnapshot: Decodable {
let communities: [BuzzPushCommunity]
}
struct BuzzPushCommunity: Decodable {
let id: String
let name: String
let relayUrl: String
let pubkey: String?
var relayURL: URL {
URL(string: relayUrl) ?? URL(string: "http://127.0.0.1")!
extension PushLeaseCommunity {
fileprivate var relayURL: URL? {
guard let url = URL(string: relayUrl),
let scheme = url.scheme?.lowercased(),
scheme == "https" || scheme == "http"
else {
return nil
}
return url
}
}