diff --git a/OpenHaystack/OpenHaystack.xcodeproj/project.pbxproj b/OpenHaystack/OpenHaystack.xcodeproj/project.pbxproj index 3c2f1ff7..610bd544 100644 --- a/OpenHaystack/OpenHaystack.xcodeproj/project.pbxproj +++ b/OpenHaystack/OpenHaystack.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 377841A52B929D650083F97A /* APISource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377841A42B929D650083F97A /* APISource.swift */; }; 5A2C9089273425720044407E /* NRF in Resources */ = {isa = PBXBuildFile; fileRef = 5A2C9088273425720044407E /* NRF */; }; 5A2C908B2734266A0044407E /* DataToHexExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A2C908A2734266A0044407E /* DataToHexExtension.swift */; }; 5A2C908D273429360044407E /* NRFController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A2C908C273429360044407E /* NRFController.swift */; }; @@ -114,6 +115,7 @@ 025DFEDB248FED250039C718 /* DecryptReports.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecryptReports.swift; sourceTree = ""; }; 0298C0C8248F9506003928FE /* AuthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthKit.framework; path = ../../../../../../../../../../System/Library/PrivateFrameworks/AuthKit.framework; sourceTree = ""; }; 116B4EEC24A913AA007BA636 /* SavePanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavePanel.swift; sourceTree = ""; }; + 377841A42B929D650083F97A /* APISource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APISource.swift; sourceTree = ""; }; 5A2C9088273425720044407E /* NRF */ = {isa = PBXFileReference; lastKnownFileType = folder; path = NRF; sourceTree = ""; }; 5A2C908A2734266A0044407E /* DataToHexExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataToHexExtension.swift; sourceTree = ""; }; 5A2C908C273429360044407E /* NRFController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NRFController.swift; sourceTree = ""; }; @@ -372,6 +374,7 @@ children = ( 78EC227125DBC8CE0042B775 /* Accessory.swift */, 78286D8B25E5355B00F65511 /* PreviewData.swift */, + 377841A42B929D650083F97A /* APISource.swift */, ); path = Model; sourceTree = ""; @@ -627,6 +630,7 @@ 7899D1E125DE97E200115740 /* IconSelectionView.swift in Sources */, 5A2C908F273429540044407E /* NRFInstallSheet.swift in Sources */, 78EC227725DBDB7E0042B775 /* KeychainController.swift in Sources */, + 377841A52B929D650083F97A /* APISource.swift in Sources */, 78D9B80625F7CF60009B9CE8 /* ManageAccessoriesView.swift in Sources */, 78486BEF25DD711E0007ED87 /* PopUpAlertView.swift in Sources */, 78014A2925DC08580089F6D9 /* MicrobitController.swift in Sources */, diff --git a/OpenHaystack/OpenHaystack/AnisetteDataManager.swift b/OpenHaystack/OpenHaystack/AnisetteDataManager.swift index 5a20ea0a..d84bd207 100644 --- a/OpenHaystack/OpenHaystack/AnisetteDataManager.swift +++ b/OpenHaystack/OpenHaystack/AnisetteDataManager.swift @@ -51,7 +51,7 @@ public class AnisetteDataManager: NSObject { } func requestAnisetteDataAuthKit() -> AppleAccountData? { - let anisetteData = ReportsFetcher().anisetteDataDictionary() + let anisetteData = AnisetteDependentReportsFetcher().anisetteDataDictionary() let dateFormatter = ISO8601DateFormatter() @@ -79,7 +79,7 @@ public class AnisetteDataManager: NSObject { locale: Locale.current, timeZone: TimeZone.current) - if let spToken = ReportsFetcher().fetchSearchpartyToken() { + if let spToken = AnisetteDependentReportsFetcher().fetchSearchpartyToken() { accountData.searchPartyToken = spToken } diff --git a/OpenHaystack/OpenHaystack/FindMy/FindMyController.swift b/OpenHaystack/OpenHaystack/FindMy/FindMyController.swift index 62f1a08c..cf9b85b9 100755 --- a/OpenHaystack/OpenHaystack/FindMy/FindMyController.swift +++ b/OpenHaystack/OpenHaystack/FindMy/FindMyController.swift @@ -16,17 +16,6 @@ class FindMyController: ObservableObject { @Published var error: Error? @Published var devices = [FindMyDevice]() - func loadPrivateKeys(from data: Data, with searchPartyToken: Data, completion: @escaping (Error?) -> Void) { - do { - let devices = try PropertyListDecoder().decode([FindMyDevice].self, from: data) - - self.devices.append(contentsOf: devices) - self.fetchReports(with: searchPartyToken, completion: completion) - } catch { - self.error = FindMyErrors.decodingPlistFailed(message: String(describing: error)) - } - } - func importReports(reports: [FindMyReport], and keys: Data, completion: @escaping () -> Void) throws { let devices = try PropertyListDecoder().decode([FindMyDevice].self, from: keys) self.devices = devices @@ -88,6 +77,14 @@ class FindMyController: ObservableObject { } func fetchReports(for accessories: [Accessory], with token: Data, completion: @escaping (Result<[FindMyDevice], Error>) -> Void) { + fetchReports(for: accessories, fetcher: AnisetteDependentReportsFetcher(searchPartyToken: token), completion: completion) + } + + func fetchReports(for accessories: [Accessory], with url: URL, authorizationHeader: String?, completion: @escaping (Result<[FindMyDevice], Error>) -> Void) { + fetchReports(for: accessories, fetcher: ExternalReportsFetcher(serverUrl: url, authorizationHeader: authorizationHeader), completion: completion) + } + + private func fetchReports(for accessories: [Accessory], fetcher: ReportsFetcher, completion: @escaping (Result<[FindMyDevice], Error>) -> Void) { let findMyDevices = accessories.compactMap({ acc -> FindMyDevice? in do { return try acc.toFindMyDevice() @@ -99,7 +96,7 @@ class FindMyController: ObservableObject { self.devices = findMyDevices - self.fetchReports(with: token) { error in + self.fetchReports(from: fetcher) { error in if let error = error { completion(.failure(error)) @@ -109,18 +106,15 @@ class FindMyController: ObservableObject { } } } - - func fetchReports(with searchPartyToken: Data, completion: @escaping (Error?) -> Void) { - + + private func fetchReports(from fetcher: ReportsFetcher, completion: @escaping (Error?) -> Void) { DispatchQueue.global(qos: .background).async { [weak self] in guard let self = self else { completion(FindMyErrors.objectReleased) return } let fetchReportGroup = DispatchGroup() - - let fetcher = ReportsFetcher() - + var devices = self.devices for deviceIndex in 0..) -> Void) { - AnisetteDataManager.shared.requestAnisetteData { [weak self] result in - guard let self = self else { - completion(.failure(.noReportsFound)) - return - } - switch result { - case .failure(_): - completion(.failure(.activatePlugin)) - case .success(let accountData): - - guard let token = accountData.searchPartyToken, - token.isEmpty == false - else { - completion(.failure(.searchPartyToken)) + switch apiSource { + case .mailPlugin: + AnisetteDataManager.shared.requestAnisetteData { [weak self] result in + guard let self = self else { + completion(.failure(.noReportsFound)) return } + switch result { + case .failure(_): + completion(.failure(.activatePlugin)) + case .success(let accountData): + + guard let token = accountData.searchPartyToken, + token.isEmpty == false + else { + completion(.failure(.searchPartyToken)) + return + } - self.findMyController.fetchReports(for: self.accessories, with: token) { [weak self] result in - switch result { - case .failure(let error): - os_log(.error, "Downloading reports failed %@", error.localizedDescription) - completion(.failure(.downloadingReportsFailed)) - case .success(let devices): - let reports = devices.compactMap({ $0.reports }).flatMap({ $0 }) - if reports.isEmpty { - completion(.failure(.noReportsFound)) - } else { - self?.updateWithDecryptedReports(devices: devices) - completion(.success(())) + self.findMyController.fetchReports(for: self.accessories, with: token) { [weak self] result in + switch result { + case .failure(let error): + os_log(.error, "Downloading reports failed %@", error.localizedDescription) + completion(.failure(.downloadingReportsFailed)) + case .success(let devices): + let reports = devices.compactMap({ $0.reports }).flatMap({ $0 }) + if reports.isEmpty { + completion(.failure(.noReportsFound)) + } else { + self?.updateWithDecryptedReports(devices: devices) + completion(.success(())) + } } } - } + } + } + case .reportsServer(let serverOptions): + guard let url = serverOptions.url else { + os_log(.error, "Downloading reports failed, no URL provided") + completion(.failure(.downloadingReportsFailed)) + return + } + + self.findMyController.fetchReports(for: self.accessories, with: url, authorizationHeader: serverOptions.authorizationHeader) { [weak self] result in + switch result { + case .failure(let error): + os_log(.error, "Downloading reports failed %@", error.localizedDescription) + completion(.failure(.downloadingReportsFailed)) + case .success(let devices): + let reports = devices.compactMap({ $0.reports }).flatMap({ $0 }) + if reports.isEmpty { + completion(.failure(.noReportsFound)) + } else { + self?.updateWithDecryptedReports(devices: devices) + completion(.success(())) + } + } } } } diff --git a/OpenHaystack/OpenHaystack/HaystackApp/Model/APISource.swift b/OpenHaystack/OpenHaystack/HaystackApp/Model/APISource.swift new file mode 100644 index 00000000..ae4c44b6 --- /dev/null +++ b/OpenHaystack/OpenHaystack/HaystackApp/Model/APISource.swift @@ -0,0 +1,86 @@ +// +// OpenHaystack – Tracking personal Bluetooth devices via Apple's Find My network +// +// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO) +// Copyright © 2021 The Open Wireless Link Project +// +// SPDX-License-Identifier: AGPL-3.0-only +// + +import Foundation + +enum APISource { + static let storageKey = "api_source" + struct ServerOptions { + var url: URL? + var authorizationHeader: String? + var isProtected: Bool { authorizationHeader != nil } + } + + case mailPlugin + case reportsServer(ServerOptions) +} + +extension APISource: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.mailPlugin, .mailPlugin), + (.reportsServer, .reportsServer): + return true + default: + return false + } + } +} + +extension APISource: Hashable { + private struct MailPluginHash: Hashable {} + private struct ReportsServerHash: Hashable {} + + func hash(into hasher: inout Hasher) { + switch self { + case .mailPlugin: + hasher.combine(MailPluginHash()) + case .reportsServer: + hasher.combine(ReportsServerHash()) + } + } +} + +extension APISource: RawRepresentable { + private static let separator: Character = "|" + private static let mailPluginIdenitifier = "mailPlugin" + private static let reportsServerIdentifier = "reportsServer" + + init?(rawValue: String) { + let components = rawValue.split(separator: APISource.separator) + guard let rawType = components.first else { return nil } + switch rawType { + case APISource.mailPluginIdenitifier: + self = .mailPlugin + case APISource.reportsServerIdentifier where components.count == 1: + self = .reportsServer(.init()) + case APISource.reportsServerIdentifier where components.count == 2: + self = .reportsServer(.init(url: URL(string: String(components[1])))) + case APISource.reportsServerIdentifier where components.count == 3: + self = .reportsServer(.init(url: URL(string: String(components[1])), authorizationHeader: String(components[2]))) + default: + return nil + } + } + + var rawValue: String { + switch self { + case .mailPlugin: + return APISource.mailPluginIdenitifier + case .reportsServer(let serverOptions): + var components: [String] = [APISource.reportsServerIdentifier] + guard let url = serverOptions.url else { return components.joined(separator: String(APISource.separator)) } + components.append(url.absoluteString) + if let authorizationHeader = serverOptions.authorizationHeader { + components.append(authorizationHeader) + } + return components.joined(separator: String(APISource.separator)) + } + } +} diff --git a/OpenHaystack/OpenHaystack/HaystackApp/Views/OpenHaystackMainView.swift b/OpenHaystack/OpenHaystack/HaystackApp/Views/OpenHaystackMainView.swift index 0ef144e5..6e3aee36 100644 --- a/OpenHaystack/OpenHaystack/HaystackApp/Views/OpenHaystackMainView.swift +++ b/OpenHaystack/OpenHaystack/HaystackApp/Views/OpenHaystackMainView.swift @@ -33,6 +33,7 @@ struct OpenHaystackMainView: View { @State var historySeconds: TimeInterval = TimeInterval.Units.day.rawValue @State var accessoryToDeploy: Accessory? @State var showMailPlugInPopover = false + @State var showSettingsPopover = false @State var mailPluginIsActive = false @@ -135,21 +136,31 @@ struct OpenHaystackMainView: View { Button( action: { - if !self.mailPluginIsActive { - self.showMailPlugInPopover.toggle() - self.checkPluginIsRunning(silent: true, nil) - } else { + switch accessoryController.apiSource { + case .mailPlugin: + if !self.mailPluginIsActive { + self.showMailPlugInPopover.toggle() + self.checkPluginIsRunning(silent: true, nil) + } else { + self.downloadLocationReports() + } + case .reportsServer: self.downloadLocationReports() } }, label: { - HStack { - Circle() - .fill(self.mailPluginIsActive ? Color.green : Color.orange) - .frame(width: 8, height: 8) + switch accessoryController.apiSource { + case .mailPlugin: + HStack { + Circle() + .fill(self.mailPluginIsActive ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Label("Reload", systemImage: "arrow.clockwise") + .disabled(!self.mailPluginIsActive) + } + case .reportsServer: Label("Reload", systemImage: "arrow.clockwise") - .disabled(!self.mailPluginIsActive) } } @@ -160,29 +171,50 @@ struct OpenHaystackMainView: View { content: { self.mailStatePopover }) + + Button( + action: { + self.showSettingsPopover.toggle() + }, + label: { + Label("Settings", systemImage: "gearshape.fill") + } + ) + .popover( + isPresented: $showSettingsPopover, + content: { + self.settingsPopover + } + ) } } func onAppear() { - /// Checks if the search party token can be fetched without the Mail Plugin. If true the plugin is not needed for this environment. (e.g. when SIP is disabled) - let reportsFetcher = ReportsFetcher() - if let token = reportsFetcher.fetchSearchpartyToken(), - let tokenString = String(data: token, encoding: .ascii) - { - self.searchPartyToken = tokenString - return - } + switch accessoryController.apiSource { + case .mailPlugin: + /// Checks if the search party token can be fetched without the Mail Plugin. If true the plugin is not needed for this environment. (e.g. when SIP is disabled) + let reportsFetcher = AnisetteDependentReportsFetcher() + if let token = reportsFetcher.fetchSearchpartyToken(), + let tokenString = String(data: token, encoding: .ascii) + { + self.searchPartyToken = tokenString + return + } - let pluginManager = MailPluginManager() + let pluginManager = MailPluginManager() + + // Check if the plugin is installed + if pluginManager.isMailPluginInstalled == false { + // Install the mail plugin + self.alertType = .activatePlugin + self.checkPluginIsRunning(silent: true, nil) + } else { + self.checkPluginIsRunning(nil) + } - // Check if the plugin is installed - if pluginManager.isMailPluginInstalled == false { - // Install the mail plugin - self.alertType = .activatePlugin - self.checkPluginIsRunning(silent: true, nil) - } else { - self.checkPluginIsRunning(nil) + case .reportsServer: + downloadLocationReports() } } @@ -225,6 +257,89 @@ struct OpenHaystackMainView: View { } .frame(width: 250, height: 120) } + + var settingsPopover: some View { + Form { + Section( + content: { + VStack(spacing: 8) { + Picker("", selection: self.$accessoryController.apiSource) { + Text("Mail Plugin").tag(APISource.mailPlugin) + Text("Server").tag(APISource.reportsServer(.init())) + } + .pickerStyle(SegmentedPickerStyle()) + + TextField( + "URL", + text: Binding( + get: { + guard case let .reportsServer(serverOptions) = $accessoryController.apiSource.wrappedValue else { return "" } + return serverOptions.url?.absoluteString ?? "" + }, + set: { mapped, transaction in + guard case var .reportsServer(serverOptions) = $accessoryController.apiSource.wrappedValue else { return } + serverOptions.url = URL(string: mapped) + $accessoryController.apiSource.transaction(transaction).wrappedValue = .reportsServer(serverOptions) + } + ) + ) + .padding(.leading, 12) + .padding(.trailing, 4) + .disabled(accessoryController.apiSource == .mailPlugin) + .foregroundColor(accessoryController.apiSource == .mailPlugin ? Color(NSColor.disabledControlTextColor) : nil) + + HStack { + Toggle( + "Is Protected?", + isOn: Binding( + get: { + guard case let .reportsServer(serverOptions) = $accessoryController.apiSource.wrappedValue else { return false } + return serverOptions.authorizationHeader != nil + }, + set: { mapped, transaction in + guard case var .reportsServer(serverOptions) = $accessoryController.apiSource.wrappedValue else { return } + serverOptions.authorizationHeader = mapped ? "" : nil + $accessoryController.apiSource.transaction(transaction).wrappedValue = .reportsServer(serverOptions) + } + ) + ) + + Spacer() + } + .padding(.leading, 12) + .disabled(accessoryController.apiSource == .mailPlugin) + + TextField( + "Authorization Header", + text: Binding( + get: { + guard case let .reportsServer(serverOptions) = $accessoryController.apiSource.wrappedValue else { return "" } + return serverOptions.authorizationHeader ?? "" + }, + set: { mapped, transaction in + guard case var .reportsServer(serverOptions) = $accessoryController.apiSource.wrappedValue else { return } + serverOptions.authorizationHeader = mapped.isEmpty ? nil : mapped + $accessoryController.apiSource.transaction(transaction).wrappedValue = .reportsServer(serverOptions) + } + ) + ) + .padding(.leading, 12) + .padding(.trailing, 4) + .disabled(accessoryController.apiSource.shouldDisableAuthorizationHeader) + .foregroundColor(accessoryController.apiSource.shouldDisableAuthorizationHeader ? Color(NSColor.disabledControlTextColor) : nil) + } + .padding(.top, 8) + }, + header: { + Text("API Source") + .fontWeight(.semibold) + .padding(.leading, -64) + } + ) + } + .padding() + .frame(width: 450) + } /// Ask to install and activate the mail plugin. func installMailPlugin() { @@ -448,3 +563,10 @@ extension TimeInterval { } } } + +private extension APISource { + var shouldDisableAuthorizationHeader: Bool { + guard case .reportsServer(let serverOptions) = self else { return true } + return serverOptions.authorizationHeader == nil + } +} diff --git a/OpenHaystack/OpenHaystack/Info.plist b/OpenHaystack/OpenHaystack/Info.plist index 060ec8b9..297609da 100644 --- a/OpenHaystack/OpenHaystack/Info.plist +++ b/OpenHaystack/OpenHaystack/Info.plist @@ -22,6 +22,11 @@ 1 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSHumanReadableCopyright Copyright © 2021 SEEMOO – TU Darmstadt NSSupportsAutomaticTermination diff --git a/OpenHaystack/OpenHaystack/ReportsFetcher/ReportsFetcher.h b/OpenHaystack/OpenHaystack/ReportsFetcher/ReportsFetcher.h index c000e4a0..e02c5973 100644 --- a/OpenHaystack/OpenHaystack/ReportsFetcher/ReportsFetcher.h +++ b/OpenHaystack/OpenHaystack/ReportsFetcher/ReportsFetcher.h @@ -39,21 +39,30 @@ NS_ASSUME_NONNULL_BEGIN - (NSString *)serverFriendlyDescription; @end -@interface ReportsFetcher : NSObject +@protocol ReportsFetcher /// WARNING: Runs synchronous network request. Please run this in a background thread. /// Query location reports for an array of public key hashes (ids) /// @param publicKeys Array of hashed public keys (in Base64) /// @param date Start date /// @param duration Duration checked -/// @param searchPartyToken Search Party token /// @param completion Called when finished - (void)queryForHashes:(NSArray *)publicKeys startDate:(NSDate *)date duration:(double)duration - searchPartyToken:(nonnull NSData *)searchPartyToken completion:(void (^)(NSData *_Nullable))completion; +@end + + +@interface AnisetteDependentReportsFetcher : NSObject + +@property (nonatomic, strong) NSData *searchPartyToken; + +- (id)init; + +- (id)initWithSearchPartyToken:(NSData *)searchPartyToken; + /// Fetches the search party token from the macOS Keychain. Returns null if it fails - (NSData *_Nullable)fetchSearchpartyToken; @@ -62,4 +71,13 @@ NS_ASSUME_NONNULL_BEGIN @end +@interface ExternalReportsFetcher : NSObject + +@property (nonatomic, strong) NSURL *serverUrl; +@property (nonatomic, strong) NSString *authorizationHeader; + +- (id)initWithServerUrl:(NSURL *)serverUrl authorizationHeader:(nullable NSString *)authorizationHeader; + +@end + NS_ASSUME_NONNULL_END diff --git a/OpenHaystack/OpenHaystack/ReportsFetcher/ReportsFetcher.m b/OpenHaystack/OpenHaystack/ReportsFetcher/ReportsFetcher.m index 76d10e5a..202507b2 100644 --- a/OpenHaystack/OpenHaystack/ReportsFetcher/ReportsFetcher.m +++ b/OpenHaystack/OpenHaystack/ReportsFetcher/ReportsFetcher.m @@ -14,7 +14,23 @@ #import "OpenHaystack-Swift.h" -@implementation ReportsFetcher +@implementation AnisetteDependentReportsFetcher + +- (id)init { + if ( self = [super init] ) { + self.searchPartyToken = [NSData alloc]; + return self; + } else + return nil; +}; + +- (id)initWithSearchPartyToken:(NSData *)searchPartyToken { + if ( self = [super init] ) { + self.searchPartyToken = searchPartyToken; + return self; + } else + return nil; +}; - (NSData *_Nullable)fetchSearchpartyToken { NSDictionary *query = @{ @@ -124,7 +140,6 @@ - (void)fetchAnisetteData:(void (^)(NSDictionary *_Nullable))completion { - (void)queryForHashes:(NSArray *)publicKeys startDate:(NSDate *)date duration:(double)duration - searchPartyToken:(nonnull NSData *)searchPartyToken completion:(void (^)(NSData *_Nullable))completion { // calculate the timestamps for the defined duration @@ -138,7 +153,7 @@ - (void)queryForHashes:(NSArray *)publicKeys NSLog(@"Query : %@", query); NSString *authKey = @"authorization"; - NSData *securityToken = searchPartyToken; + NSData *securityToken = self.searchPartyToken; NSString *appleId = [self fetchAppleAccountId]; NSString *authValue = [self basicAuthForAppleID:appleId andToken:securityToken]; @@ -176,3 +191,56 @@ - (void)queryForHashes:(NSArray *)publicKeys } @end + +@implementation ExternalReportsFetcher + +- (id)initWithServerUrl:(NSURL *)serverUrl authorizationHeader:(nullable NSString *)authorizationHeader; { + if ( self = [super init] ) { + _serverUrl = serverUrl; + _authorizationHeader = authorizationHeader; + return self; + } else + return nil; +}; + +- (void)queryForHashes:(NSArray *)publicKeys + startDate:(NSDate *)date + duration:(double)duration + completion:(void (^)(NSData *_Nullable))completion { + + // calculate the timestamps for the defined duration + long long startDate = [date timeIntervalSince1970] * 1000; + long long endDate = ([date timeIntervalSince1970] + duration) * 1000.0; + + NSLog(@"Requesting data for %@", publicKeys); + NSDictionary *query = + @{@"search" : @[ @{@"endDate" : [NSString stringWithFormat:@"%lli", endDate], @"ids" : publicKeys, @"startDate" : [NSString stringWithFormat:@"%lli", startDate]} ]}; + NSData *httpBody = [NSJSONSerialization dataWithJSONObject:query options:0 error:nil]; + + NSLog(@"Query : %@", query); + + NSMutableURLRequest *req = [[NSMutableURLRequest alloc] initWithURL:[[NSURL alloc] initWithString:self.serverUrl.absoluteString]]; + + [req setHTTPMethod:@"POST"]; + [req setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + [req setValue:@"application/json" forHTTPHeaderField:@"Accept"]; + if (_authorizationHeader) { + [req setValue:_authorizationHeader forHTTPHeaderField:@"Authorization"]; + } + + NSLog(@"Headers:\n%@", req.allHTTPHeaderFields); + + [req setHTTPBody:httpBody]; + + NSURLResponse *response; + NSError *error = nil; + NSData *data = [NSURLConnection sendSynchronousRequest:req returningResponse:&response error:&error]; + + if (error) { + NSLog(@"Error during request: \n\n%@", error); + } + + completion(data); +} + +@end