Skip to content

Commit a5619a4

Browse files
Merge branch 'main' into feat/noid/apppassword-onetime
2 parents e15226e + 462b1fc commit a5619a4

7 files changed

Lines changed: 413 additions & 138 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// SPDX-FileCopyrightText: Nextcloud GmbH
2+
// SPDX-FileCopyrightText: 2025 Milen Pivchev
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
import Foundation
6+
import Alamofire
7+
8+
public struct NKClientIntegration: Codable {
9+
public let apps: [String: AppContext]
10+
11+
public init(from decoder: Decoder) throws {
12+
let container = try decoder.container(keyedBy: DynamicKey.self)
13+
14+
var dict: [String: AppContext] = [:]
15+
for key in container.allKeys {
16+
let value = try container.decode(AppContext.self, forKey: key)
17+
dict[key.stringValue] = value
18+
}
19+
self.apps = dict
20+
}
21+
22+
public func encode(to encoder: Encoder) throws {
23+
var container = encoder.container(keyedBy: DynamicKey.self)
24+
for (key, value) in apps {
25+
try container.encode(value, forKey: DynamicKey(stringValue: key)!)
26+
}
27+
}
28+
29+
public enum Params: String {
30+
case fileId = "fileId"
31+
case filePath = "filePath"
32+
}
33+
}
34+
35+
struct DynamicKey: CodingKey {
36+
var stringValue: String
37+
var intValue: Int? { nil }
38+
init?(stringValue: String) { self.stringValue = stringValue }
39+
init?(intValue: Int) { return nil }
40+
}
41+
42+
public struct AppContext: Codable {
43+
public let version: Double
44+
public let contextMenu: [ContextMenuAction]
45+
46+
enum CodingKeys: String, CodingKey {
47+
case version
48+
case contextMenu = "context-menu"
49+
}
50+
}
51+
52+
public struct ContextMenuAction: Codable {
53+
public let name: String
54+
public let url: String
55+
public let method: String
56+
public let mimetypeFilters: String?
57+
public let params: [String: String]?
58+
public let icon: String?
59+
60+
enum CodingKeys: String, CodingKey {
61+
case name, url, method, icon, params
62+
case mimetypeFilters = "mimetype_filters"
63+
}
64+
}
65+
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// SPDX-FileCopyrightText: Nextcloud GmbH
2+
// SPDX-FileCopyrightText: 2025 Milen Pivchev
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
public struct NKClientIntegrationUIResponse: Codable {
6+
public let ocs: OCSContainer
7+
}
8+
9+
public struct OCSContainer: Codable {
10+
public let meta: Meta
11+
public let data: ResponseData
12+
}
13+
14+
public struct Meta: Codable {
15+
public let status: String
16+
public let statuscode: Int
17+
public let message: String
18+
}
19+
20+
public struct ResponseData: Codable {
21+
public let version: Double
22+
public let tooltip: String?
23+
public let root: RootContainer?
24+
}
25+
26+
public struct RootContainer: Codable {
27+
public let orientation: String
28+
public let rows: [Row]
29+
}
30+
31+
public struct Row: Codable {
32+
public let children: [Child]
33+
}
34+
35+
public struct Child: Codable {
36+
public let element: String
37+
public let text: String
38+
public let url: String
39+
}

Sources/NextcloudKit/Models/NKProperties.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public enum NKProperties: String, CaseIterable {
6565
/// open-cloud-mesh.org
6666
case sharepermissionscloudmesh = "<share-permissions xmlns=\"http://open-cloud-mesh.org/ns\"/>"
6767

68-
static func properties(createProperties: [NKProperties]?, removeProperties: [NKProperties] = []) -> String {
68+
static public func properties(createProperties: [NKProperties]?, removeProperties: [NKProperties] = []) -> String {
6969
var properties = allCases.map { $0.rawValue }.joined()
7070
if let createProperties {
7171
properties = ""

Sources/NextcloudKit/NKMonitor.swift

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,78 @@
55
import Foundation
66
import Alamofire
77

8-
final class NKMonitor: EventMonitor, Sendable {
9-
let nkCommonInstance: NKCommon
10-
let queue = DispatchQueue(label: "com.nextcloud.NKMonitor")
8+
// Description:
9+
//
10+
// NKMonitor is an Alamofire EventMonitor implementation used to observe
11+
// the lifecycle of network requests and responses within the Nextcloud iOS client.
12+
//
13+
// Its primary responsibilities are:
14+
//
15+
// - Logging outgoing requests and incoming responses at different verbosity levels.
16+
// - Tracking server-side error codes per account for diagnostic and recovery purposes.
17+
// - Detecting potential account mismatches between the logical account assigned
18+
// to a request and the user encoded in the WebDAV request path.
19+
//
20+
// Account Safety and Diagnostics:
21+
//
22+
// In a multi-account environment, it is critical to ensure that each request
23+
// is executed using the correct account credentials.
24+
//
25+
// To support this, NKMonitor:
26+
//
27+
// - Extracts the logical account identifier from a custom internal HTTP header
28+
// attached to each request.
29+
// - On authentication failures (HTTP 401), compares the account identifier
30+
// against the username declared in the WebDAV path (e.g. /remote.php/dav/files/<user>).
31+
// - Logs an explicit error when a mismatch is detected, providing deterministic
32+
// evidence of a request executed with inconsistent account context.
33+
//
34+
// This mechanism allows distinguishing between:
35+
// - Legitimate authentication failures for the correct account.
36+
// - Requests accidentally executed using credentials belonging to a different account.
37+
//
38+
// Threading Model:
39+
//
40+
// - All logging operations are performed on a dedicated background DispatchQueue.
41+
// - The monitor does not assume any actor isolation and is intentionally not Sendable.
42+
// - Consumers of delegate callbacks are responsible for ensuring thread safety.
43+
//
44+
// Security Notes:
45+
//
46+
// - Authorization headers are never inspected or decoded.
47+
// - Only application-internal account identifiers are logged.
48+
// - No credentials or sensitive authentication material are exposed.
49+
//
50+
// NKMonitor is intended as an observational and diagnostic component and does not
51+
// modify request execution or response handling.
52+
//
53+
54+
final class NKMonitor: EventMonitor {
55+
internal let nkCommonInstance: NKCommon
56+
internal let queue = DispatchQueue(label: "com.nextcloud.NKMonitor", qos: .utility)
1157

1258
init(nkCommonInstance: NKCommon) {
1359
self.nkCommonInstance = nkCommonInstance
1460
}
1561

1662
func requestDidResume(_ request: Request) {
17-
DispatchQueue.global(qos: .utility).async {
63+
guard let urlRequest = request.request else {
64+
// URLRequest not created yet → skip logging
65+
return
66+
}
67+
let account = urlRequest.allHTTPHeaderFields?[self.nkCommonInstance.headerAccount] ?? "unknown"
68+
69+
queue.async {
1870
switch NKLogFileManager.shared.logLevel {
1971
case .normal:
2072
// General-purpose log: full Request description
21-
nkLog(info: "Request started: \(request)")
73+
nkLog(info: "User: \(account) - Request started: \(request)")
2274
case .verbose:
2375
// Full dump: headers + body
24-
let headers = request.request?.allHTTPHeaderFields?.description ?? "None"
25-
let body = request.request?.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "None"
26-
76+
let headers = urlRequest.allHTTPHeaderFields?.description ?? "None"
77+
let body = urlRequest.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "None"
78+
79+
nkLog(debug: "User: \(account)")
2780
nkLog(debug: "Request started: \(request)")
2881
nkLog(debug: "Headers: \(headers)")
2982
nkLog(debug: "Body: \(body)")
@@ -35,6 +88,7 @@ final class NKMonitor: EventMonitor, Sendable {
3588

3689
func request<Value>(_ request: DataRequest, didParseResponse response: AFDataResponse<Value>) {
3790
nkCommonInstance.delegate?.request(request, didParseResponse: response)
91+
let account = request.request?.allHTTPHeaderFields?[self.nkCommonInstance.headerAccount] ?? "unknown"
3892

3993
// Check for header and account error code tracking
4094
if let statusCode = response.response?.statusCode,
@@ -46,31 +100,44 @@ final class NKMonitor: EventMonitor, Sendable {
46100
}
47101
}
48102

49-
DispatchQueue.global(qos: .utility).async {
103+
// Check 401
104+
if response.response?.statusCode == 401 {
105+
let pathUser = request.request?.url?
106+
.path
107+
.components(separatedBy: "/files/")
108+
.dropFirst()
109+
.first
110+
111+
if let pathUser, pathUser != account {
112+
nkLog(error: "ACCOUNT MISMATCH host=\(request.request?.url?.host ?? "-") pathUser=\(pathUser) headerUser=\(account)")
113+
}
114+
}
115+
116+
queue.async {
50117
switch NKLogFileManager.shared.logLevel {
51118
case .normal:
52119
let resultString = String(describing: response.result)
53-
54120
if let request = response.request {
55-
nkLog(info: "Network response request: \(request), result: \(resultString)")
121+
nkLog(info: "User: \(account) - Network response request: \(request), result: \(resultString)")
56122
} else {
57-
nkLog(info: "Network response result: \(resultString)")
123+
nkLog(info: "User: \(account) - Network response result: \(resultString)")
58124
}
59125

60126
case .compact:
61127
if let method = request.request?.httpMethod,
62128
let url = request.request?.url?.absoluteString,
63129
let code = response.response?.statusCode {
64130

65-
let responseStatus = (200..<300).contains(code) ? "RESPONSE: SUCCESS" : "RESPONSE: ERROR"
66-
nkLog(network: "\(code) \(method) \(url) \(responseStatus)")
131+
let responseStatus = (200..<300).contains(code) ? "Response: SUCCESS" : "Response: ERROR"
132+
nkLog(network: "User: \(account) Code: \(code) Method: \(method) Url: \(url) - \(responseStatus)")
67133
}
68134

69135
case .verbose:
70136
let debugDesc = String(describing: response)
71137
let headerFields = String(describing: response.response?.allHeaderFields ?? [:])
72138
let date = Date().formatted(using: "yyyy-MM-dd' 'HH:mm:ss")
73139

140+
nkLog(debug: "User: \(account)")
74141
nkLog(debug: "Network response result: \(date) " + debugDesc)
75142
nkLog(debug: "Network response all headers: \(date) " + headerFields)
76143

Sources/NextcloudKit/NextcloudKit+Capabilities.swift

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ public extension NextcloudKit {
132132
let assistant: Assistant?
133133
let recommendations: Recommendations?
134134
let termsOfService: TermsOfService?
135+
let clientIntegration: NKClientIntegration?
135136

136137
enum CodingKeys: String, CodingKey {
137138
case downloadLimit = "downloadlimit"
@@ -145,6 +146,7 @@ public extension NextcloudKit {
145146
case assistant
146147
case recommendations
147148
case termsOfService = "terms_of_service"
149+
case clientIntegration = "client_integration"
148150
}
149151

150152
struct DownloadLimit: Codable {
@@ -348,6 +350,32 @@ public extension NextcloudKit {
348350
struct Recommendations: Codable {
349351
let enabled: Bool?
350352
}
353+
354+
// struct DeclarativeUI: Codable {
355+
// let contextMenu: [[ContextMenuItem]]
356+
//
357+
// enum CodingKeys: String, CodingKey {
358+
// case contextMenu = "context-menu"
359+
// }
360+
// }
361+
362+
//
363+
// struct DeclarativeUI: Codable {
364+
// let contextMenus: [ContextMenu]
365+
//
366+
// enum CodingKeys: String, CodingKey {
367+
// case contextMenus = "context-menu"
368+
// }
369+
//
370+
// struct ContextMenu: Codable {
371+
// let items
372+
// }
373+
//
374+
// struct ContextMenuItem: Codable {
375+
// let title: String
376+
// let endpoint: String
377+
// }
378+
// }
351379
}
352380
}
353381
}
@@ -365,6 +393,8 @@ public extension NextcloudKit {
365393
let data = decoded.ocs.data
366394
let json = data.capabilities
367395

396+
print(json)
397+
368398
// Initialize capabilities
369399
let capabilities = NKCapabilities.Capabilities()
370400

@@ -434,6 +464,8 @@ public extension NextcloudKit {
434464
capabilities.recommendations = json.recommendations?.enabled ?? false
435465
capabilities.termsOfService = json.termsOfService?.enabled ?? false
436466

467+
capabilities.clientIntegration = json.clientIntegration
468+
437469
// Persist capabilities in shared store
438470
await NKCapabilities.shared.setCapabilities(for: account, capabilities: capabilities)
439471
return capabilities
@@ -528,7 +560,9 @@ final public class NKCapabilities: Sendable {
528560
public var forbiddenFileNameExtensions: [String] = []
529561
public var recommendations: Bool = false
530562
public var termsOfService: Bool = false
531-
563+
// public var declarativeUIEnabled: Bool = false
564+
// public var declarativeUIContextMenu: [ContextMenuItem] = []
565+
public var clientIntegration: NKClientIntegration? = nil
532566
public var directEditingEditors: [NKEditorDetailsEditor] = []
533567
public var directEditingCreators: [NKEditorDetailsCreator] = []
534568
public var directEditingTemplates: [NKEditorTemplate] = []

0 commit comments

Comments
 (0)