/* Copyright 2014 Google Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ #if !defined(__has_feature) || !__has_feature(objc_arc) #error "This file requires ARC support." #endif #include <sys/stat.h> #include <unistd.h> #import "GTMSessionFetcher/GTMSessionFetcherLogging.h" #ifndef STRIP_GTM_FETCH_LOGGING #error GTMSessionFetcher headers should have defaulted this if it wasn't already defined. #endif #if !STRIP_GTM_FETCH_LOGGING // Sensitive credential strings are replaced in logs with _snip_ // // Apps that must see the contents of sensitive tokens can set this to 1 #ifndef SKIP_GTM_FETCH_LOGGING_SNIPPING #define SKIP_GTM_FETCH_LOGGING_SNIPPING 0 #endif // If GTMReadMonitorInputStream is available, it can be used for // capturing uploaded streams of data // // We locally declare methods of GTMReadMonitorInputStream so we // do not need to import the header, as some projects may not have it available #if !GTMSESSION_BUILD_COMBINED_SOURCES @interface GTMReadMonitorInputStream : NSInputStream + (instancetype)inputStreamWithStream:(NSInputStream *)input; @property(assign) id readDelegate; @property(assign) SEL readSelector; @end #else @class GTMReadMonitorInputStream; #endif // !GTMSESSION_BUILD_COMBINED_SOURCES @interface GTMSessionFetcher (GTMSessionFetcherLoggingUtilities) + (NSString *)headersStringForDictionary:(NSDictionary *)dict; + (NSString *)snipSubstringOfString:(NSString *)originalStr betweenStartString:(NSString *)startStr endString:(NSString *)endStr; - (void)inputStream:(GTMReadMonitorInputStream *)stream readIntoBuffer:(void *)buffer length:(int64_t)length; @end @implementation GTMSessionFetcher (GTMSessionFetcherLogging) // fetchers come and fetchers go, but statics are forever static BOOL gIsLoggingEnabled = NO; static BOOL gIsLoggingToFile = YES; static NSString *gLoggingDirectoryPath = nil; static NSString *gLogDirectoryForCurrentRun = nil; static NSString *gLoggingDateStamp = nil; static NSString *gLoggingProcessName = nil; + (void)setLoggingDirectory:(NSString *)path { gLoggingDirectoryPath = [path copy]; } + (NSString *)loggingDirectory { if (!gLoggingDirectoryPath) { NSArray *paths = nil; #if TARGET_IPHONE_SIMULATOR // default to a directory called GTMHTTPDebugLogs into a sandbox-safe // directory that a developer can find easily, the application home paths = @[ NSHomeDirectory() ]; #elif TARGET_OS_IPHONE // Neither ~/Desktop nor ~/Home is writable on an actual iOS, watchOS, or tvOS device. // Put it in ~/Documents. paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); #else // default to a directory called GTMHTTPDebugLogs in the desktop folder paths = NSSearchPathForDirectoriesInDomains(NSDesktopDirectory, NSUserDomainMask, YES); #endif NSString *desktopPath = paths.firstObject; if (desktopPath) { NSString *const kGTMLogFolderName = @"GTMHTTPDebugLogs"; NSString *logsFolderPath = [desktopPath stringByAppendingPathComponent:kGTMLogFolderName]; NSFileManager *fileMgr = [NSFileManager defaultManager]; BOOL isDir; BOOL doesFolderExist = [fileMgr fileExistsAtPath:logsFolderPath isDirectory:&isDir]; if (!doesFolderExist) { // make the directory doesFolderExist = [fileMgr createDirectoryAtPath:logsFolderPath withIntermediateDirectories:YES attributes:nil error:NULL]; if (doesFolderExist) { // The directory has been created. Exclude it from backups. NSURL *pathURL = [NSURL fileURLWithPath:logsFolderPath isDirectory:YES]; [pathURL setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:NULL]; } } if (doesFolderExist) { // it's there; store it in the global gLoggingDirectoryPath = [logsFolderPath copy]; } } } return gLoggingDirectoryPath; } + (void)setLogDirectoryForCurrentRun:(NSString *)logDirectoryForCurrentRun { // Set the path for this run's logs. gLogDirectoryForCurrentRun = [logDirectoryForCurrentRun copy]; } + (NSString *)logDirectoryForCurrentRun { // make a directory for this run's logs, like SyncProto_logs_10-16_01-56-58PM if (gLogDirectoryForCurrentRun) return gLogDirectoryForCurrentRun; NSString *parentDir = [self loggingDirectory]; NSString *logNamePrefix = [self processNameLogPrefix]; NSString *dateStamp = [self loggingDateStamp]; NSString *dirName = [NSString stringWithFormat:@"%@%@", logNamePrefix, dateStamp]; NSString *logDirectory = [parentDir stringByAppendingPathComponent:dirName]; if (gIsLoggingToFile) { NSFileManager *fileMgr = [NSFileManager defaultManager]; // Be sure that the first time this app runs, it's not writing to a preexisting folder static BOOL gShouldReuseFolder = NO; if (!gShouldReuseFolder) { gShouldReuseFolder = YES; NSString *origLogDir = logDirectory; for (int ctr = 2; ctr < 20; ++ctr) { if (![fileMgr fileExistsAtPath:logDirectory]) break; // append a digit logDirectory = [origLogDir stringByAppendingFormat:@"_%d", ctr]; } } if (![fileMgr createDirectoryAtPath:logDirectory withIntermediateDirectories:YES attributes:nil error:NULL]) return nil; } gLogDirectoryForCurrentRun = logDirectory; return gLogDirectoryForCurrentRun; } + (void)setLoggingEnabled:(BOOL)isLoggingEnabled { gIsLoggingEnabled = isLoggingEnabled; } + (BOOL)isLoggingEnabled { return gIsLoggingEnabled; } + (void)setLoggingToFileEnabled:(BOOL)isLoggingToFileEnabled { gIsLoggingToFile = isLoggingToFileEnabled; } + (BOOL)isLoggingToFileEnabled { return gIsLoggingToFile; } + (void)setLoggingProcessName:(NSString *)processName { gLoggingProcessName = [processName copy]; } + (NSString *)loggingProcessName { // get the process name (once per run) replacing spaces with underscores if (!gLoggingProcessName) { NSString *procName = [[NSProcessInfo processInfo] processName]; gLoggingProcessName = [procName stringByReplacingOccurrencesOfString:@" " withString:@"_"]; } return gLoggingProcessName; } + (void)setLoggingDateStamp:(NSString *)dateStamp { gLoggingDateStamp = [dateStamp copy]; } + (NSString *)loggingDateStamp { // We'll pick one date stamp per run, so a run that starts at a later second // will get a unique results html file if (!gLoggingDateStamp) { // produce a string like 08-21_01-41-23PM NSDateFormatter *formatter = [[NSDateFormatter alloc] init]; [formatter setFormatterBehavior:NSDateFormatterBehavior10_4]; [formatter setDateFormat:@"M-dd_hh-mm-ssa"]; gLoggingDateStamp = [formatter stringFromDate:[NSDate date]]; } return gLoggingDateStamp; } + (NSString *)processNameLogPrefix { static NSString *gPrefix = nil; if (!gPrefix) { NSString *processName = [self loggingProcessName]; gPrefix = [[NSString alloc] initWithFormat:@"%@_log_", processName]; } return gPrefix; } + (NSString *)symlinkNameSuffix { return @"_log_newest.html"; } + (NSString *)htmlFileName { return @"aperçu_http_log.html"; } + (void)deleteLogDirectoriesOlderThanDate:(NSDate *)cutoffDate { NSFileManager *fileMgr = [NSFileManager defaultManager]; NSURL *parentDir = [NSURL fileURLWithPath:[[self class] loggingDirectory]]; NSURL *logDirectoryForCurrentRun = [NSURL fileURLWithPath:[[self class] logDirectoryForCurrentRun]]; NSError *error; NSArray *contents = [fileMgr contentsOfDirectoryAtURL:parentDir includingPropertiesForKeys:@[ NSURLContentModificationDateKey ] options:0 error:&error]; for (NSURL *itemURL in contents) { if ([itemURL isEqual:logDirectoryForCurrentRun]) continue; NSDate *modDate; if ([itemURL getResourceValue:&modDate forKey:NSURLContentModificationDateKey error:&error]) { if ([modDate compare:cutoffDate] == NSOrderedAscending) { if (![fileMgr removeItemAtURL:itemURL error:&error]) { NSLog(@"deleteLogDirectoriesOlderThanDate failed to delete %@: %@", itemURL.path, error); } } } else { NSLog(@"deleteLogDirectoriesOlderThanDate failed to get mod date of %@: %@", itemURL.path, error); } } } // formattedStringFromData returns a prettyprinted string for JSON input, // and a plain string for other input data - (NSString *)formattedStringFromData:(NSData *)inputData contentType:(NSString *)contentType JSON:(NSDictionary **)outJSON { if (!inputData) return nil; // if the content type is JSON and we have the parsing class available, use that if ([contentType hasPrefix:@"application/json"] && inputData.length > 5) { // convert from JSON string to NSObjects and back to a formatted string NSMutableDictionary *obj = [NSJSONSerialization JSONObjectWithData:inputData options:NSJSONReadingMutableContainers error:NULL]; if (obj) { if (outJSON) *outJSON = obj; if ([obj isKindOfClass:[NSMutableDictionary class]]) { // for security and privacy, omit OAuth 2 response access and refresh tokens if ([obj valueForKey:@"refresh_token"] != nil) { [obj setObject:@"_snip_" forKey:@"refresh_token"]; } if ([obj valueForKey:@"access_token"] != nil) { [obj setObject:@"_snip_" forKey:@"access_token"]; } } NSData *data = [NSJSONSerialization dataWithJSONObject:obj options:NSJSONWritingPrettyPrinted error:NULL]; if (data) { NSString *jsonStr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; return jsonStr; } } } NSString *dataStr = [[NSString alloc] initWithData:inputData encoding:NSUTF8StringEncoding]; return dataStr; } // stringFromStreamData creates a string given the supplied data // // If NSString can create a UTF-8 string from the data, then that is returned. // // Otherwise, this routine tries to find a MIME boundary at the beginning of the data block, and // uses that to break up the data into parts. Each part will be used to try to make a UTF-8 string. // For parts that fail, a replacement string showing the part header and <<n bytes>> is supplied // in place of the binary data. - (NSString *)stringFromStreamData:(NSData *)data contentType:(NSString *)contentType { if (!data) return nil; // optimistically, see if the whole data block is UTF-8 NSString *streamDataStr = [self formattedStringFromData:data contentType:contentType JSON:NULL]; if (streamDataStr) return streamDataStr; // Munge a buffer by replacing non-ASCII bytes with underscores, and turn that munged buffer an // NSString. That gives us a string we can use with NSScanner. NSMutableData *mutableData = [NSMutableData dataWithData:data]; unsigned char *bytes = (unsigned char *)mutableData.mutableBytes; for (unsigned int idx = 0; idx < mutableData.length; ++idx) { if (bytes[idx] > 0x7F || bytes[idx] == 0) { bytes[idx] = '_'; } } NSString *mungedStr = [[NSString alloc] initWithData:mutableData encoding:NSUTF8StringEncoding]; if (mungedStr) { // scan for the boundary string NSString *boundary = nil; NSScanner *scanner = [NSScanner scannerWithString:mungedStr]; if ([scanner scanUpToString:@"\r\n" intoString:&boundary] && [boundary hasPrefix:@"--"]) { // we found a boundary string; use it to divide the string into parts NSArray *mungedParts = [mungedStr componentsSeparatedByString:boundary]; // look at each munged part in the original string, and try to convert those into UTF-8 NSMutableArray *origParts = [NSMutableArray array]; NSUInteger offset = 0; for (NSString *mungedPart in mungedParts) { NSUInteger partSize = mungedPart.length; NSData *origPartData = [data subdataWithRange:NSMakeRange(offset, partSize)]; NSString *origPartStr = [[NSString alloc] initWithData:origPartData encoding:NSUTF8StringEncoding]; if (origPartStr) { // we could make this original part into UTF-8; use the string [origParts addObject:origPartStr]; } else { // this part can't be made into UTF-8; scan the header, if we can NSString *header = nil; NSScanner *headerScanner = [NSScanner scannerWithString:mungedPart]; if (![headerScanner scanUpToString:@"\r\n\r\n" intoString:&header]) { // we couldn't find a header header = @""; } // make a part string with the header and <<n bytes>> NSString *binStr = [NSString stringWithFormat:@"\r%@\r<<%lu bytes>>\r", header, (long)(partSize - header.length)]; [origParts addObject:binStr]; } offset += partSize + boundary.length; } // rejoin the original parts streamDataStr = [origParts componentsJoinedByString:boundary]; } } if (!streamDataStr) { // give up; just make a string showing the uploaded bytes streamDataStr = [NSString stringWithFormat:@"<<%u bytes>>", (unsigned int)data.length]; } return streamDataStr; } // logFetchWithError is called following a successful or failed fetch attempt // // This method does all the work for appending to and creating log files - (void)logFetchWithError:(NSError *)error { if (![[self class] isLoggingEnabled]) return; NSString *logDirectory = [[self class] logDirectoryForCurrentRun]; if (!logDirectory) return; NSString *processName = [[self class] loggingProcessName]; // TODO: add Javascript to display response data formatted in hex // each response's NSData goes into its own xml or txt file, though all responses for this run of // the app share a main html file. This counter tracks all fetch responses for this app run. // // we'll use a local variable since this routine may be reentered while waiting for formatting // to be completed. static int gResponseCounter = 0; int responseCounter = ++gResponseCounter; NSURLResponse *response = [self response]; NSDictionary *responseHeaders = [self responseHeaders]; NSString *responseDataStr = nil; NSDictionary *responseJSON = nil; // if there's response data, decide what kind of file to put it in based on the first bytes of the // file or on the mime type supplied by the server NSString *responseMIMEType = [response MIMEType]; BOOL isResponseImage = NO; // file name for an image data file NSString *responseDataFileName = nil; int64_t responseDataLength = self.downloadedLength; if (responseDataLength > 0) { NSData *downloadedData = self.downloadedData; if (downloadedData == nil && responseDataLength > 0 && responseDataLength < 20000 && self.destinationFileURL) { // There's a download file that's not too big, so get the data to display from the downloaded // file. NSURL *destinationURL = self.destinationFileURL; downloadedData = [NSData dataWithContentsOfURL:destinationURL]; } NSString *responseType = [responseHeaders valueForKey:@"Content-Type"]; responseDataStr = [self formattedStringFromData:downloadedData contentType:responseType JSON:&responseJSON]; NSString *responseDataExtn = nil; NSData *dataToWrite = nil; if ([responseMIMEType isEqual:@"application/atom+xml"] || [responseMIMEType hasSuffix:@"/xml"]) { responseDataExtn = @"xml"; dataToWrite = downloadedData; } else if ([responseMIMEType isEqual:@"image/jpeg"]) { responseDataExtn = @"jpg"; dataToWrite = downloadedData; isResponseImage = YES; } else if ([responseMIMEType isEqual:@"image/gif"]) { responseDataExtn = @"gif"; dataToWrite = downloadedData; isResponseImage = YES; } else if ([responseMIMEType isEqual:@"image/png"]) { responseDataExtn = @"png"; dataToWrite = downloadedData; isResponseImage = YES; } else { // add more non-text types here } // if we have an extension, save the raw data in a file with that extension if (responseDataExtn && dataToWrite) { // generate a response file base name like NSString *responseBaseName = [NSString stringWithFormat:@"fetch_%d_response", responseCounter]; responseDataFileName = [responseBaseName stringByAppendingPathExtension:responseDataExtn]; NSString *responseDataFilePath = [logDirectory stringByAppendingPathComponent:responseDataFileName]; NSError *downloadedError = nil; if (gIsLoggingToFile && ![dataToWrite writeToFile:responseDataFilePath options:0 error:&downloadedError]) { NSLog(@"%@ logging write error:%@ (%@)", [self class], downloadedError, responseDataFileName); } } } // we'll have one main html file per run of the app NSString *htmlName = [[self class] htmlFileName]; NSString *htmlPath = [logDirectory stringByAppendingPathComponent:htmlName]; // if the html file exists (from logging previous fetches) we don't need // to re-write the header or the scripts NSFileManager *fileMgr = [NSFileManager defaultManager]; BOOL didFileExist = [fileMgr fileExistsAtPath:htmlPath]; NSMutableString *outputHTML = [NSMutableString string]; // we need a header to say we'll have UTF-8 text if (!didFileExist) { [outputHTML appendFormat:@"<html><head><meta http-equiv=\"content-type\" " "content=\"text/html; charset=UTF-8\"><title>%@ HTTP fetch log %@</title>", processName, [[self class] loggingDateStamp]]; } // now write the visible html elements NSString *copyableFileName = [NSString stringWithFormat:@"fetch_%d.txt", responseCounter]; NSDate *now = [NSDate date]; // write the date & time, the comment, and the link to the plain-text (copyable) log [outputHTML appendFormat:@"<b>%@ ", now]; NSString *comment = [self comment]; if (comment.length > 0) { [outputHTML appendFormat:@"%@ ", comment]; } [outputHTML appendFormat:@"</b><a href='%@'><i>request/response log</i></a><br>", copyableFileName]; NSTimeInterval elapsed = -self.initialBeginFetchDate.timeIntervalSinceNow; [outputHTML appendFormat:@"elapsed: %5.3fsec<br>", elapsed]; // write the request URL NSURLRequest *request = self.request; NSString *requestMethod = request.HTTPMethod; NSURL *requestURL = request.URL; // Save the request URL for next time in case this redirects. NSString *redirectedFromURLString = [self.redirectedFromURL absoluteString]; self.redirectedFromURL = [requestURL copy]; if (redirectedFromURLString) { [outputHTML appendFormat:@"<FONT COLOR='#990066'><i>redirected from %@</i></FONT><br>", redirectedFromURLString]; } [outputHTML appendFormat:@"<b>request:</b> %@ <code>%@</code><br>\n", requestMethod, requestURL]; // write the request headers NSDictionary *requestHeaders = request.allHTTPHeaderFields; NSUInteger numberOfRequestHeaders = requestHeaders.count; if (numberOfRequestHeaders > 0) { // Indicate if the request is authorized; warn if the request is authorized but non-SSL NSString *auth = [requestHeaders objectForKey:@"Authorization"]; NSString *headerDetails = @""; if (auth) { BOOL isInsecure = [[requestURL scheme] isEqual:@"http"]; if (isInsecure) { // 26A0 = ⚠ headerDetails = @" <i>authorized, non-SSL</i><FONT COLOR='#FF00FF'> ⚠</FONT> "; } else { headerDetails = @" <i>authorized</i>"; } } NSString *cookiesHdr = [requestHeaders objectForKey:@"Cookie"]; if (cookiesHdr) { headerDetails = [headerDetails stringByAppendingString:@" <i>cookies</i>"]; } NSString *matchHdr = [requestHeaders objectForKey:@"If-Match"]; if (matchHdr) { headerDetails = [headerDetails stringByAppendingString:@" <i>if-match</i>"]; } matchHdr = [requestHeaders objectForKey:@"If-None-Match"]; if (matchHdr) { headerDetails = [headerDetails stringByAppendingString:@" <i>if-none-match</i>"]; } [outputHTML appendFormat:@" headers: %d %@<br>", (int)numberOfRequestHeaders, headerDetails]; } else { [outputHTML appendFormat:@" headers: none<br>"]; } // write the request post data NSData *bodyData = nil; NSData *loggedStreamData = self.loggedStreamData; if (loggedStreamData) { bodyData = loggedStreamData; } else { bodyData = self.bodyData; if (bodyData == nil) { bodyData = self.request.HTTPBody; } } uint64_t bodyDataLength = bodyData.length; if (bodyData.length == 0) { // If the data is in a body upload file URL, read that in if it's not huge. NSURL *bodyFileURL = self.bodyFileURL; if (bodyFileURL) { NSNumber *fileSizeNum = nil; NSError *fileSizeError = nil; if ([bodyFileURL getResourceValue:&fileSizeNum forKey:NSURLFileSizeKey error:&fileSizeError]) { bodyDataLength = [fileSizeNum unsignedLongLongValue]; if (bodyDataLength > 0 && bodyDataLength < 50000) { bodyData = [NSData dataWithContentsOfURL:bodyFileURL options:NSDataReadingUncached error:&fileSizeError]; } } } } NSString *bodyDataStr = nil; NSString *postType = [requestHeaders valueForKey:@"Content-Type"]; if (bodyDataLength > 0) { [outputHTML appendFormat:@" data: %llu bytes, <code>%@</code><br>\n", bodyDataLength, postType ? postType : @"(no type)"]; NSString *logRequestBody = self.logRequestBody; if (logRequestBody) { bodyDataStr = [logRequestBody copy]; self.logRequestBody = nil; } else { bodyDataStr = [self stringFromStreamData:bodyData contentType:postType]; if (bodyDataStr) { // remove OAuth 2 client secret and refresh token bodyDataStr = [[self class] snipSubstringOfString:bodyDataStr betweenStartString:@"client_secret=" endString:@"&"]; bodyDataStr = [[self class] snipSubstringOfString:bodyDataStr betweenStartString:@"refresh_token=" endString:@"&"]; // remove ClientLogin password bodyDataStr = [[self class] snipSubstringOfString:bodyDataStr betweenStartString:@"&Passwd=" endString:@"&"]; } } } else { // no post data } // write the response status, MIME type, URL NSInteger status = [self statusCode]; if (response) { NSString *statusString = @""; if (status != 0) { if (status == 200 || status == 201) { statusString = [NSString stringWithFormat:@"%ld", (long)status]; // report any JSON-RPC error if ([responseJSON isKindOfClass:[NSDictionary class]]) { NSDictionary *jsonError = [responseJSON objectForKey:@"error"]; if ([jsonError isKindOfClass:[NSDictionary class]]) { NSString *jsonCode = [[jsonError valueForKey:@"code"] description]; NSString *jsonMessage = [jsonError valueForKey:@"message"]; if (jsonCode || jsonMessage) { // 2691 = ⚑ NSString *const jsonErrFmt = @" <i>JSON error:</i> <FONT " @"COLOR='#FF00FF'>%@ %@ ⚑</FONT>"; statusString = [statusString stringByAppendingFormat:jsonErrFmt, jsonCode ? jsonCode : @"", jsonMessage ? jsonMessage : @""]; } } } } else { // purple for anything other than 200 or 201 NSString *flag = status >= 400 ? @" ⚑" : @""; // 2691 = ⚑ NSString *explanation = [NSHTTPURLResponse localizedStringForStatusCode:status]; NSString *const statusFormat = @"<FONT COLOR='#FF00FF'>%ld %@ %@</FONT>"; statusString = [NSString stringWithFormat:statusFormat, (long)status, explanation, flag]; } } // show the response URL only if it's different from the request URL NSString *responseURLStr = @""; NSURL *responseURL = response.URL; if (responseURL && ![responseURL isEqual:request.URL]) { NSString *const responseURLFormat = @"<FONT COLOR='#FF00FF'>response URL:</FONT> <code>%@</code><br>\n"; responseURLStr = [NSString stringWithFormat:responseURLFormat, [responseURL absoluteString]]; } [outputHTML appendFormat:@"<b>response:</b> status %@<br>\n%@", statusString, responseURLStr]; // Write the response headers NSUInteger numberOfResponseHeaders = responseHeaders.count; if (numberOfResponseHeaders > 0) { // Indicate if the server is setting cookies NSString *cookiesSet = [responseHeaders valueForKey:@"Set-Cookie"]; NSString *cookiesStr = cookiesSet ? @" <FONT COLOR='#990066'><i>sets cookies</i></FONT>" : @""; // Indicate if the server is redirecting NSString *location = [responseHeaders valueForKey:@"Location"]; BOOL isRedirect = status >= 300 && status <= 399 && location != nil; NSString *redirectsStr = isRedirect ? @" <FONT COLOR='#990066'><i>redirects</i></FONT>" : @""; [outputHTML appendFormat:@" headers: %d %@ %@<br>\n", (int)numberOfResponseHeaders, cookiesStr, redirectsStr]; } else { [outputHTML appendString:@" headers: none<br>\n"]; } } // error if (error) { [outputHTML appendFormat:@"<b>Error:</b> %@ <br>\n", error.description]; } // Write the response data if (responseDataFileName) { if (isResponseImage) { // Make a small inline image that links to the full image file [outputHTML appendFormat:@" data: %lld bytes, <code>%@</code><br>", responseDataLength, responseMIMEType]; NSString *const fmt = @"<a href=\"%@\"><img src='%@' alt='image' style='border:solid " @"thin;max-height:32'></a>\n"; [outputHTML appendFormat:fmt, responseDataFileName, responseDataFileName]; } else { // The response data was XML; link to the xml file NSString *const fmt = @" data: %lld bytes, " @"<code>%@</code> <i><a href=\"%@\">%@</a></i>\n"; [outputHTML appendFormat:fmt, responseDataLength, responseMIMEType, responseDataFileName, [responseDataFileName pathExtension]]; } } else { // The response data was not an image; just show the length and MIME type [outputHTML appendFormat:@" data: %lld bytes, <code>%@</code>\n", responseDataLength, responseMIMEType ? responseMIMEType : @"(no response type)"]; } // Make a single string of the request and response, suitable for copying // to the clipboard and pasting into a bug report NSMutableString *copyable = [NSMutableString string]; if (comment) { [copyable appendFormat:@"%@\n\n", comment]; } [copyable appendFormat:@"%@ elapsed: %5.3fsec\n", now, elapsed]; if (redirectedFromURLString) { [copyable appendFormat:@"Redirected from %@\n", redirectedFromURLString]; } [copyable appendFormat:@"Request: %@ %@\n", requestMethod, requestURL]; if (requestHeaders.count > 0) { [copyable appendFormat:@"Request headers:\n%@\n", [[self class] headersStringForDictionary:requestHeaders]]; } if (bodyDataLength > 0) { [copyable appendFormat:@"Request body: (%llu bytes)\n", bodyDataLength]; if (bodyDataStr) { [copyable appendFormat:@"%@\n", bodyDataStr]; } [copyable appendString:@"\n"]; } if (response) { [copyable appendFormat:@"Response: status %d\n", (int)status]; [copyable appendFormat:@"Response headers:\n%@\n", [[self class] headersStringForDictionary:responseHeaders]]; [copyable appendFormat:@"Response body: (%lld bytes)\n", responseDataLength]; if (responseDataLength > 0) { NSString *logResponseBody = self.logResponseBody; if (logResponseBody) { // The user has provided the response body text. responseDataStr = [logResponseBody copy]; self.logResponseBody = nil; } if (responseDataStr != nil) { [copyable appendFormat:@"%@\n", responseDataStr]; } else { // Even though it's redundant, we'll put in text to indicate that all the bytes are binary. if (self.destinationFileURL) { [copyable appendFormat:@"<<%lld bytes>> to file %@\n", responseDataLength, self.destinationFileURL.path]; } else { [copyable appendFormat:@"<<%lld bytes>>\n", responseDataLength]; } } } } if (error) { [copyable appendFormat:@"Error: %@\n", error]; } // Save to log property before adding the separator self.log = copyable; [copyable appendString:@"-----------------------------------------------------------\n"]; // Write the copyable version to another file (linked to at the top of the html file, above) // // Ideally, something to just copy this to the clipboard like // <span onCopy='window.event.clipboardData.setData(\"Text\", // \"copyable stuff\");return false;'>Copy here.</span>" // would work everywhere, but it only works in Safari as of 8/2010 if (gIsLoggingToFile) { NSString *parentDir = [[self class] loggingDirectory]; NSString *copyablePath = [logDirectory stringByAppendingPathComponent:copyableFileName]; NSError *copyableError = nil; if (![copyable writeToFile:copyablePath atomically:NO encoding:NSUTF8StringEncoding error:©ableError]) { // Error writing to file NSLog(@"%@ logging write error:%@ (%@)", [self class], copyableError, copyablePath); } [outputHTML appendString:@"<br><hr><p>"]; // Append the HTML to the main output file const char *htmlBytes = outputHTML.UTF8String; NSOutputStream *stream = [NSOutputStream outputStreamToFileAtPath:htmlPath append:YES]; [stream open]; [stream write:(const uint8_t *)htmlBytes maxLength:strlen(htmlBytes)]; [stream close]; // Make a symlink to the latest html NSString *const symlinkNameSuffix = [[self class] symlinkNameSuffix]; NSString *symlinkName = [processName stringByAppendingString:symlinkNameSuffix]; NSString *symlinkPath = [parentDir stringByAppendingPathComponent:symlinkName]; [fileMgr removeItemAtPath:symlinkPath error:NULL]; [fileMgr createSymbolicLinkAtPath:symlinkPath withDestinationPath:htmlPath error:NULL]; #if TARGET_OS_IPHONE static BOOL gReportedLoggingPath = NO; if (!gReportedLoggingPath) { gReportedLoggingPath = YES; NSLog(@"GTMSessionFetcher logging to \"%@\"", parentDir); } #endif } } - (NSInputStream *)loggedInputStreamForInputStream:(NSInputStream *)inputStream { if (!inputStream) return nil; if (![GTMSessionFetcher isLoggingEnabled]) return inputStream; [self clearLoggedStreamData]; // Clear any previous data. Class monitorClass = NSClassFromString(@"GTMReadMonitorInputStream"); if (!monitorClass) { NSString const *str = @"<<Uploaded stream log unavailable without GTMReadMonitorInputStream>>"; NSData *stringData = [str dataUsingEncoding:NSUTF8StringEncoding]; [self appendLoggedStreamData:stringData]; return inputStream; } inputStream = [monitorClass inputStreamWithStream:inputStream]; GTMReadMonitorInputStream *readMonitorInputStream = (GTMReadMonitorInputStream *)inputStream; [readMonitorInputStream setReadDelegate:self]; SEL readSel = @selector(inputStream:readIntoBuffer:length:); [readMonitorInputStream setReadSelector:readSel]; return inputStream; } - (GTMSessionFetcherBodyStreamProvider)loggedStreamProviderForStreamProvider: (GTMSessionFetcherBodyStreamProvider)streamProvider { if (!streamProvider) return nil; if (![GTMSessionFetcher isLoggingEnabled]) return streamProvider; [self clearLoggedStreamData]; // Clear any previous data. Class monitorClass = NSClassFromString(@"GTMReadMonitorInputStream"); if (!monitorClass) { NSString const *str = @"<<Uploaded stream log unavailable without GTMReadMonitorInputStream>>"; NSData *stringData = [str dataUsingEncoding:NSUTF8StringEncoding]; [self appendLoggedStreamData:stringData]; return streamProvider; } GTMSessionFetcherBodyStreamProvider loggedStreamProvider = ^(GTMSessionFetcherBodyStreamProviderResponse response) { streamProvider(^(NSInputStream *bodyStream) { bodyStream = [self loggedInputStreamForInputStream:bodyStream]; response(bodyStream); }); }; return loggedStreamProvider; } @end @implementation GTMSessionFetcher (GTMSessionFetcherLoggingUtilities) - (void)inputStream:(GTMReadMonitorInputStream *)stream readIntoBuffer:(void *)buffer length:(int64_t)length { // append the captured data NSData *data = [NSData dataWithBytesNoCopy:buffer length:(NSUInteger)length freeWhenDone:NO]; [self appendLoggedStreamData:data]; } #pragma mark Fomatting Utilities + (NSString *)snipSubstringOfString:(NSString *)originalStr betweenStartString:(NSString *)startStr endString:(NSString *)endStr { #if SKIP_GTM_FETCH_LOGGING_SNIPPING return originalStr; #else if (!originalStr) return nil; // Find the start string, and replace everything between it // and the end string (or the end of the original string) with "_snip_" NSRange startRange = [originalStr rangeOfString:startStr]; if (startRange.location == NSNotFound) return originalStr; // We found the start string NSUInteger originalLength = originalStr.length; NSUInteger startOfTarget = NSMaxRange(startRange); NSRange targetAndRest = NSMakeRange(startOfTarget, originalLength - startOfTarget); NSRange endRange = [originalStr rangeOfString:endStr options:0 range:targetAndRest]; NSRange replaceRange; if (endRange.location == NSNotFound) { // Found no end marker so replace to end of string replaceRange = targetAndRest; } else { // Replace up to the endStr replaceRange = NSMakeRange(startOfTarget, endRange.location - startOfTarget); } NSString *result = [originalStr stringByReplacingCharactersInRange:replaceRange withString:@"_snip_"]; return result; #endif // SKIP_GTM_FETCH_LOGGING_SNIPPING } + (NSString *)headersStringForDictionary:(NSDictionary *)dict { // Format the dictionary in http header style, like // Accept: application/json // Cache-Control: no-cache // Content-Type: application/json; charset=utf-8 // // Pad the key names, but not beyond 16 chars, since long custom header // keys just create too much whitespace NSArray *keys = [dict.allKeys sortedArrayUsingSelector:@selector(compare:)]; NSMutableString *str = [NSMutableString string]; for (NSString *key in keys) { NSString *value = [dict valueForKey:key]; if ([key isEqual:@"Authorization"]) { // Remove OAuth 1 token value = [[self class] snipSubstringOfString:value betweenStartString:@"oauth_token=\"" endString:@"\""]; // Remove OAuth 2 bearer token (draft 16, and older form) value = [[self class] snipSubstringOfString:value betweenStartString:@"Bearer " endString:@"\n"]; value = [[self class] snipSubstringOfString:value betweenStartString:@"OAuth " endString:@"\n"]; // Remove Google ClientLogin value = [[self class] snipSubstringOfString:value betweenStartString:@"GoogleLogin auth=" endString:@"\n"]; } [str appendFormat:@" %@: %@\n", key, value]; } return str; } @end #endif // !STRIP_GTM_FETCH_LOGGING