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
4 changes: 4 additions & 0 deletions InterlinedList.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
5F81C584710958DCBF85F60E /* AIServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC10FAF5FB93F8317D2D91A /* AIServiceError.swift */; };
61621697746E8CF1769E9C80 /* IdentityHealth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D1D19E4DB2D914AD6551D7 /* IdentityHealth.swift */; };
64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */; };
9F0095A1C4E24B7D0095E002 /* APIClientQueryValueEncodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0095A1C4E24B7D0095E001 /* APIClientQueryValueEncodingTests.swift */; };
C0FFEE9102ABCDEF00000091 /* ComposeLinkDetectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE9101ABCDEF00000091 /* ComposeLinkDetectionTests.swift */; };
6749119D27FA93BE00D5A27F /* FeedTruncationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */; };
6A89622E299B0D172D5B5556 /* GitHubModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C337406C10876F331B1888E3 /* GitHubModelTests.swift */; };
Expand Down Expand Up @@ -274,6 +275,7 @@
0221B233F89B966D044C37FA /* ServerLimits.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServerLimits.swift; sourceTree = "<group>"; };
03C47A3ECFD7F4D25F91E3BE /* ShareLinksSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareLinksSheet.swift; sourceTree = "<group>"; };
03D6C33FD59101503195F14F /* SharedDocumentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedDocumentView.swift; sourceTree = "<group>"; };
9F0095A1C4E24B7D0095E001 /* APIClientQueryValueEncodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientQueryValueEncodingTests.swift; sourceTree = "<group>"; };
C0FFEE9101ABCDEF00000091 /* ComposeLinkDetectionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeLinkDetectionTests.swift; sourceTree = "<group>"; };
05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedTruncationTests.swift; sourceTree = "<group>"; };
06CAA8B0033D6DD27ED089CD /* ShareInvitesSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInvitesSheet.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -824,6 +826,7 @@
E46B33ABF62A3438ADF98885 /* APIClientAppSettingsTests.swift */,
FCCD5BA8427B40000116FC43 /* APIClientDocumentPresenceTests.swift */,
EA635B97637F46345E2F6347 /* APIClientDMConversationsTests.swift */,
9F0095A1C4E24B7D0095E001 /* APIClientQueryValueEncodingTests.swift */,
);
path = APIClientTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -1238,6 +1241,7 @@
572EF654CD322007FCC0A68C /* DocumentPresenceServiceTests.swift in Sources */,
9DFDF38F0AFBF42EB781E5E5 /* ListRowSortingTests.swift in Sources */,
D74FA886D6F1AE873E679EA5 /* APIClientDMConversationsTests.swift in Sources */,
9F0095A1C4E24B7D0095E002 /* APIClientQueryValueEncodingTests.swift in Sources */,
F49DE4618E3EDFBDE2D66B1A /* ListRowComposeTextTests.swift in Sources */,
F5ADADB7D863EAB9BD4996C6 /* ViewPreferencesTests.swift in Sources */,
9C983C24B85DBE20FCDF1873 /* APIClientAppSettingsTests.swift in Sources */,
Expand Down
11 changes: 1 addition & 10 deletions InterlinedList/Services/APIClient+DirectMessages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,12 @@ extension APIClient {
func dmConversations(cursor: String? = nil, take: Int? = nil) async throws -> DMConversationPage {
var query: [String] = []
if let cursor, !cursor.isEmpty {
query.append("cursor=" + Self.encodedQueryValue(cursor))
query.append("cursor=" + queryValue(cursor))
}
if let take {
query.append("take=\(take)")
}
let suffix = query.isEmpty ? "" : "?" + query.joined(separator: "&")
return try await get("/api/dm/conversations" + suffix)
}

/// `.urlQueryAllowed` permits `+`, `=`, `&` and `/`, all of which appear in the
/// base64 keyset cursor this route issues. Left unescaped, a `+` decodes as a
/// space server-side and the cursor silently stops matching — the page repeats
/// or ends early. Escape them explicitly.
static func encodedQueryValue(_ raw: String) -> String {
let allowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "+&=?#/"))
return raw.addingPercentEncoding(withAllowedCharacters: allowed) ?? raw
}
}
18 changes: 2 additions & 16 deletions InterlinedList/Services/APIClient+Organizations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,6 @@

import Foundation

/// `.urlQueryAllowed` deliberately permits the sub-delimiters `&`, `=`, `+` and
/// `?` — they are legal *somewhere* in a query string. Encoding a value with it
/// therefore lets a user-typed `&` split the query into an extra parameter, so a
/// search for `"ada l&ve"` reaches the backend as `search=ada l`. Strip the
/// delimiters so a value stays one value. `+` is included because the backend
/// reads params via `URLSearchParams`, which decodes `+` as a space.
private let orgQueryValueAllowed: CharacterSet = {
var allowed = CharacterSet.urlQueryAllowed
allowed.remove(charactersIn: "&=+?#")
return allowed
}()

/// Organization discovery and member recruitment.
///
/// These are the two reads that `APIClient`'s existing `addOrganizationMember`
Expand All @@ -43,13 +31,11 @@ extension APIClient {
let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
var path = "/api/organizations/\(encoded)/users?limit=\(limit)&offset=\(offset)"
if let search, !search.isEmpty {
let query = search.addingPercentEncoding(withAllowedCharacters: orgQueryValueAllowed) ?? search
path += "&search=\(query)"
path += "&search=\(queryValue(search))"
}
if let excludeMembers, !excludeMembers.isEmpty {
let joined = excludeMembers.joined(separator: ",")
let query = joined.addingPercentEncoding(withAllowedCharacters: orgQueryValueAllowed) ?? joined
path += "&excludeMembers=\(query)"
path += "&excludeMembers=\(queryValue(joined))"
}
let response: OrganizationUsersResponse = try await get(path)
return response.users
Expand Down
25 changes: 7 additions & 18 deletions InterlinedList/Services/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,20 +176,10 @@ final class APIClient {
/// with two rows for the same provider loses both); the identity's own id is
/// not read at all.
func unlinkIdentity(provider: String) async throws {
let encoded = provider.addingPercentEncoding(withAllowedCharacters: Self.queryValueAllowed) ?? provider
let encoded = queryValue(provider)
try await delete("/api/user/identities?provider=\(encoded)")
}

/// `.urlQueryAllowed` permits the sub-delimiters `+ & = ? # /` — legal
/// *somewhere* in a query string, but inside a single value they end it or
/// split it into another parameter. `+` matters most: the backend reads params
/// through `URLSearchParams`, which decodes a literal `+` as a space.
private static let queryValueAllowed: CharacterSet = {
var allowed = CharacterSet.urlQueryAllowed
allowed.remove(charactersIn: "+&=?#/")
return allowed
}()

/// Carries the verify route's two "credential is unusable" statuses out of the
/// transport, which can only signal by throwing, back to the outcome value
/// `verifyIdentity` returns.
Expand Down Expand Up @@ -703,7 +693,7 @@ final class APIClient {
}

func searchDocuments(q: String, limit: Int = 20, offset: Int = 0) async throws -> ([Document], Pagination?) {
let qEncoded = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? q
let qEncoded = queryValue(q)
struct Response: Decodable { let documents: [Document]; let pagination: Pagination? }
let response: Response = try await get("/api/documents/search?q=\(qEncoded)&limit=\(limit)&offset=\(offset)")
return (response.documents, response.pagination)
Expand Down Expand Up @@ -775,7 +765,7 @@ final class APIClient {
}

func searchLists(q: String, limit: Int = 20, offset: Int = 0) async throws -> ([UserList], Pagination?) {
let qEncoded = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? q
let qEncoded = queryValue(q)
struct Response: Decodable { let lists: [UserList]; let pagination: Pagination? }
let response: Response = try await get("/api/lists/search?q=\(qEncoded)&limit=\(limit)&offset=\(offset)")
return (response.lists, response.pagination)
Expand Down Expand Up @@ -1176,8 +1166,7 @@ final class APIClient {
let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId
var path = "/api/lists/\(encoded)/watchers/users?limit=\(limit)&offset=\(offset)"
if let search, !search.isEmpty {
let q = search.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? search
path += "&search=\(q)"
path += "&search=\(queryValue(search))"
}
let response: WatcherCandidatesResponse = try await get(path)
return response.users
Expand Down Expand Up @@ -1288,7 +1277,7 @@ final class APIClient {

func searchDocumentCollaboratorCandidates(id: String, query: String) async throws -> [WatcherCandidate] {
let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
let q = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
let q = queryValue(query)
let response: WatcherCandidatesResponse = try await get("/api/documents/\(encoded)/collaborators/users?q=\(q)")
return response.users
}
Expand Down Expand Up @@ -1399,7 +1388,7 @@ final class APIClient {
// MARK: - Message search (Phase 13 / B2)

func searchMessages(q: String, limit: Int = 20, offset: Int = 0) async throws -> (messages: [Message], pagination: Pagination?) {
let qEncoded = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? q
let qEncoded = queryValue(q)
let response: MessagesResponse = try await get("/api/messages/search?q=\(qEncoded)&limit=\(limit)&offset=\(offset)")
return (response.messages, response.pagination)
}
Expand Down Expand Up @@ -1527,7 +1516,7 @@ final class APIClient {
/// Auto-marks received messages read.
func dmThreadUpdates(username: String, after: String) async throws -> DMThread {
let encodedUser = username.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? username
let encodedAfter = after.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? after
let encodedAfter = queryValue(after)
return try await get("/api/dm/thread/\(encodedUser)/updates?after=\(encodedAfter)")
}

Expand Down
22 changes: 20 additions & 2 deletions InterlinedList/Services/APIClientTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import os.log

private let transportLog = Logger(subsystem: "com.interlinedlist.app", category: "APIClient")

/// `.urlQueryAllowed` permits the sub-delimiters `+ & = ? # /` — legal *somewhere*
/// in a query string, but inside a single value they end it or split it into
/// another parameter. `+` matters most: the backend reads params through
/// `URLSearchParams`, which decodes a literal `+` as a space.
private let queryValueAllowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "+&=?#/"))

/// The HTTP seam every `APIClient` endpoint is built on: URL assembly, auth
/// header, body encoding, status checking, decoding.
///
Expand Down Expand Up @@ -192,12 +198,24 @@ extension APIClient {
return data
}

/// Percent-encodes one path segment. `?? segment` keeps the call sites free
/// of force-unwraps; encoding only fails for inputs a path can't hold anyway.
// MARK: - URL encoding

/// Percent-encodes one path *segment* — the part between two slashes. `??
/// segment` keeps the call sites free of force-unwraps; encoding only fails
/// for inputs a path can't hold anyway.
func pathSegment(_ segment: String) -> String {
segment.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? segment
}

/// Percent-encodes one query *value* — whatever follows a `=` in the query
/// string, typically a user-typed search term or a server-issued opaque
/// cursor. Not interchangeable with `pathSegment(_:)`: see
/// `queryValueAllowed` for the sub-delimiters this has to strip and that one
/// must not.
func queryValue(_ value: String) -> String {
value.addingPercentEncoding(withAllowedCharacters: queryValueAllowed) ?? value
}

// MARK: - Private

private func jsonRequest(_ path: String, method: String, authenticated: Bool = true) throws -> URLRequest {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import XCTest
@testable import InterlinedList

/// Every route that puts a user-typed term or a server-issued opaque cursor into
/// a query value, checked through the one shared `queryValue(_:)` encoder.
///
/// Two failure modes are being guarded against, and only one of them is visible
/// to `URLComponents`: a raw `&` or `=` splits the value into an extra query
/// parameter (so the round-trip and the parameter count both catch it), while a
/// raw `+` survives `URLComponents` intact and only goes wrong on the backend,
/// which reads params through `URLSearchParams` and decodes `+` as a space.
/// That is why each case also asserts the *wire* form carries no literal `+`.
final class APIClientQueryValueEncodingTests: XCTestCase {
var sut: APIClient!
var session: MockURLSession!

/// Contains all four of `+`, `&`, `=` and a space.
private let awkwardTerm = "c++ & a=b"
/// Base64 alphabet output, containing `+`, `/` and the `=` padding.
private let base64Cursor = "YWJjKz0vZGVm+/w=="

override func setUp() {
super.setUp()
session = MockURLSession()
sut = APIClient(session: session)
sut.setBearerToken("tok")
}

private func assertQueryValueSurvives(_ expected: String,
named name: String,
parameterCount: Int,
file: StaticString = #filePath,
line: UInt = #line) throws {
let url = try XCTUnwrap(session.lastRequest?.url, file: file, line: line)
let rawQuery = url.query ?? ""
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
XCTAssertEqual(items.count, parameterCount,
"The value must not split into extra parameters: \(rawQuery)",
file: file, line: line)
XCTAssertEqual(items.first(where: { $0.name == name })?.value, expected,
"Expected \(name) to round-trip unchanged: \(rawQuery)",
file: file, line: line)
XCTAssertFalse(rawQuery.contains("+"),
"A literal + reaches the backend as a space: \(rawQuery)",
file: file, line: line)
}

// MARK: - Search terms

func test_searchMessages_termWithQueryDelimiters_roundTripsUnchanged() async throws {
session.stub(json: #"{"messages":[],"pagination":null}"#)
_ = try await sut.searchMessages(q: awkwardTerm)
try assertQueryValueSurvives(awkwardTerm, named: "q", parameterCount: 3)
}

func test_searchDocuments_termWithQueryDelimiters_roundTripsUnchanged() async throws {
session.stub(json: #"{"documents":[],"pagination":null}"#)
_ = try await sut.searchDocuments(q: awkwardTerm)
try assertQueryValueSurvives(awkwardTerm, named: "q", parameterCount: 3)
}

func test_searchLists_termWithQueryDelimiters_roundTripsUnchanged() async throws {
session.stub(json: #"{"lists":[],"pagination":null}"#)
_ = try await sut.searchLists(q: awkwardTerm)
try assertQueryValueSurvives(awkwardTerm, named: "q", parameterCount: 3)
}

func test_searchWatcherCandidates_termWithQueryDelimiters_roundTripsUnchanged() async throws {
session.stub(json: #"{"users":[],"total":0}"#)
_ = try await sut.searchWatcherCandidates(listId: "list-1", search: awkwardTerm)
try assertQueryValueSurvives(awkwardTerm, named: "search", parameterCount: 3)
}

func test_searchDocumentCollaboratorCandidates_termWithQueryDelimiters_roundTripsUnchanged() async throws {
session.stub(json: #"{"users":[],"total":0}"#)
_ = try await sut.searchDocumentCollaboratorCandidates(id: "doc-1", query: awkwardTerm)
try assertQueryValueSurvives(awkwardTerm, named: "q", parameterCount: 1)
}

func test_organizationUsers_termWithQueryDelimiters_roundTripsUnchanged() async throws {
session.stub(json: #"{"users":[],"total":0}"#)
_ = try await sut.organizationUsers(id: "org-1", search: awkwardTerm)
try assertQueryValueSurvives(awkwardTerm, named: "search", parameterCount: 3)
}

// MARK: - Opaque cursors

func test_dmThreadUpdates_base64CursorWithPadding_roundTripsUnchanged() async throws {
session.stub(json: threadJSON)
_ = try await sut.dmThreadUpdates(username: "bob", after: base64Cursor)
try assertQueryValueSurvives(base64Cursor, named: "after", parameterCount: 1)
}

func test_dmConversations_base64CursorWithPadding_roundTripsUnchanged() async throws {
session.stub(json: #"{"items":[],"nextCursor":null}"#)
_ = try await sut.dmConversations(cursor: base64Cursor)
try assertQueryValueSurvives(base64Cursor, named: "cursor", parameterCount: 1)
}

func test_unlinkIdentity_providerWithQueryDelimiters_roundTripsUnchanged() async throws {
session.stub(data: Data(), statusCode: 204)
try await sut.unlinkIdentity(provider: "mastodon:a+b&c=d")
try assertQueryValueSurvives("mastodon:a+b&c=d", named: "provider", parameterCount: 1)
}

// MARK: - Path encoding is left alone

/// `pathSegment(_:)` and `queryValue(_:)` are not interchangeable: a slash in
/// a *path* segment still has to be escaped, and encoding the two halves of a
/// URL with one set would break whichever half it wasn't chosen for.
func test_dmThreadUpdates_usernameStaysPathEncoded() async throws {
session.stub(json: threadJSON)
_ = try await sut.dmThreadUpdates(username: "bob smith", after: "m1")
XCTAssertEqual(session.lastRequest?.url?.path, "/api/dm/thread/bob smith/updates")
}

private let threadJSON = #"""
{"items":[],"olderCursor":null,"isMutual":true,"isBlocked":false,
"otherUser":{"id":"r1","username":"bob","displayName":"Bob","avatar":null}}
"""#
}
Loading