| /* Copyright (c) 2010 Google Inc. |
| * |
| * 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 !STRIP_GTM_FETCH_LOGGING |
| |
| #include <sys/stat.h> |
| #include <unistd.h> |
| |
| #import "GTMHTTPFetcherLogging.h" |
| |
| // 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 |
| #ifndef GTM_NSSTREAM_DELEGATE |
| @interface GTMReadMonitorInputStream : NSInputStream |
| + (instancetype)inputStreamWithStream:(NSInputStream *)input; |
| @property (assign) id readDelegate; |
| @property (assign) SEL readSelector; |
| @property (retain) NSArray *runLoopModes; |
| @end |
| #endif |
| |
| // If GTMNSJSONSerialization is available, it is used for formatting JSON |
| #if (TARGET_OS_MAC && !TARGET_OS_IPHONE && (MAC_OS_X_VERSION_MAX_ALLOWED < 1070)) || \ |
| (TARGET_OS_IPHONE && (__IPHONE_OS_VERSION_MAX_ALLOWED < 50000)) |
| @interface GTMNSJSONSerialization : NSObject |
| + (NSData *)dataWithJSONObject:(id)obj options:(NSUInteger)opt error:(NSError **)error; |
| + (id)JSONObjectWithData:(NSData *)data options:(NSUInteger)opt error:(NSError **)error; |
| @end |
| #endif |
| |
| // Otherwise, if SBJSON is available, it is used for formatting JSON |
| @interface GTMFetcherSBJSON |
| - (void)setHumanReadable:(BOOL)flag; |
| - (NSString*)stringWithObject:(id)value error:(NSError**)error; |
| - (id)objectWithString:(NSString*)jsonrep error:(NSError**)error; |
| @end |
| |
| @interface GTMHTTPFetcher (GTMHTTPFetcherLoggingUtilities) |
| + (NSString *)headersStringForDictionary:(NSDictionary *)dict; |
| |
| - (void)inputStream:(GTMReadMonitorInputStream *)stream |
| readIntoBuffer:(void *)buffer |
| length:(NSUInteger)length; |
| |
| // internal file utilities for logging |
| + (BOOL)fileOrDirExistsAtPath:(NSString *)path; |
| + (BOOL)makeDirectoryUpToPath:(NSString *)path; |
| + (BOOL)removeItemAtPath:(NSString *)path; |
| + (BOOL)createSymbolicLinkAtPath:(NSString *)newPath |
| withDestinationPath:(NSString *)targetPath; |
| |
| + (NSString *)snipSubstringOfString:(NSString *)originalStr |
| betweenStartString:(NSString *)startStr |
| endString:(NSString *)endStr; |
| |
| + (id)JSONObjectWithData:(NSData *)data; |
| + (id)stringWithJSONObject:(id)obj; |
| @end |
| |
| @implementation GTMHTTPFetcher (GTMHTTPFetcherLogging) |
| |
| // fetchers come and fetchers go, but statics are forever |
| static BOOL gIsLoggingEnabled = NO; |
| static BOOL gIsLoggingToFile = YES; |
| static NSString *gLoggingDirectoryPath = nil; |
| static NSString *gLoggingDateStamp = nil; |
| static NSString *gLoggingProcessName = nil; |
| static NSString *gLogDirectoryForCurrentRun = nil; |
| |
| + (void)setLoggingDirectory:(NSString *)path { |
| [gLoggingDirectoryPath autorelease]; |
| gLoggingDirectoryPath = [path copy]; |
| } |
| |
| + (NSString *)loggingDirectory { |
| |
| if (!gLoggingDirectoryPath) { |
| NSArray *arr = nil; |
| #if GTM_IPHONE && TARGET_IPHONE_SIMULATOR |
| // default to a directory called GTMHTTPDebugLogs into a sandbox-safe |
| // directory that a developer can find easily, the application home |
| arr = [NSArray arrayWithObject:NSHomeDirectory()]; |
| #elif GTM_IPHONE |
| // Neither ~/Desktop nor ~/Home is writable on an actual iPhone device. |
| // Put it in ~/Documents. |
| arr = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, |
| NSUserDomainMask, YES); |
| #else |
| // default to a directory called GTMHTTPDebugLogs in the desktop folder |
| arr = NSSearchPathForDirectoriesInDomains(NSDesktopDirectory, |
| NSUserDomainMask, YES); |
| #endif |
| |
| if ([arr count] > 0) { |
| NSString *const kGTMLogFolderName = @"GTMHTTPDebugLogs"; |
| |
| NSString *desktopPath = [arr objectAtIndex:0]; |
| NSString *logsFolderPath = [desktopPath stringByAppendingPathComponent:kGTMLogFolderName]; |
| |
| BOOL doesFolderExist = [[self class] fileOrDirExistsAtPath:logsFolderPath]; |
| |
| if (!doesFolderExist) { |
| // make the directory |
| doesFolderExist = [self makeDirectoryUpToPath:logsFolderPath]; |
| } |
| |
| if (doesFolderExist) { |
| // it's there; store it in the global |
| gLoggingDirectoryPath = [logsFolderPath copy]; |
| } |
| } |
| } |
| return gLoggingDirectoryPath; |
| } |
| |
| + (NSString *)logDirectoryForCurrentRun { |
| if (gLogDirectoryForCurrentRun) return gLogDirectoryForCurrentRun; |
| |
| NSString *parentDir = [[self class] loggingDirectory]; |
| NSString *dateStamp = [[self class] loggingDateStamp]; |
| NSString *logNamePrefix = [[self class] processNameLogPrefix]; |
| |
| // make a directory for this run's logs, like |
| // SyncProto_logs_10-16_01-56-58PM |
| NSString *dirName = [NSString stringWithFormat:@"%@%@", |
| logNamePrefix, dateStamp]; |
| NSString *logDirectory = [parentDir stringByAppendingPathComponent:dirName]; |
| |
| if (gIsLoggingToFile) { |
| // be sure that the first time this app runs, it's not writing to |
| // a preexisting folder |
| static BOOL shouldReuseFolder = NO; |
| if (!shouldReuseFolder) { |
| shouldReuseFolder = YES; |
| NSString *origLogDir = logDirectory; |
| for (int ctr = 2; ctr < 20; ctr++) { |
| if (![[self class] fileOrDirExistsAtPath:logDirectory]) break; |
| |
| // append a digit |
| logDirectory = [origLogDir stringByAppendingFormat:@"_%d", ctr]; |
| } |
| } |
| if (![[self class] makeDirectoryUpToPath:logDirectory]) return nil; |
| } |
| gLogDirectoryForCurrentRun = [logDirectory copy]; |
| return gLogDirectoryForCurrentRun; |
| } |
| |
| + (void)setLoggingEnabled:(BOOL)flag { |
| gIsLoggingEnabled = flag; |
| } |
| |
| + (BOOL)isLoggingEnabled { |
| return gIsLoggingEnabled; |
| } |
| |
| + (void)setLoggingToFileEnabled:(BOOL)flag { |
| gIsLoggingToFile = flag; |
| } |
| |
| + (BOOL)isLoggingToFileEnabled { |
| return gIsLoggingToFile; |
| } |
| |
| + (void)setLoggingProcessName:(NSString *)str { |
| [gLoggingProcessName release]; |
| gLoggingProcessName = [str copy]; |
| } |
| |
| + (NSString *)loggingProcessName { |
| |
| // get the process name (once per run) replacing spaces with underscores |
| if (!gLoggingProcessName) { |
| |
| NSString *procName = [[NSProcessInfo processInfo] processName]; |
| NSMutableString *loggingProcessName; |
| loggingProcessName = [[NSMutableString alloc] initWithString:procName]; |
| |
| [loggingProcessName replaceOccurrencesOfString:@" " |
| withString:@"_" |
| options:0 |
| range:NSMakeRange(0, [loggingProcessName length])]; |
| gLoggingProcessName = loggingProcessName; |
| } |
| return gLoggingProcessName; |
| } |
| |
| + (void)setLoggingDateStamp:(NSString *)str { |
| [gLoggingDateStamp release]; |
| gLoggingDateStamp = [str 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] autorelease]; |
| [formatter setFormatterBehavior:NSDateFormatterBehavior10_4]; |
| [formatter setDateFormat:@"M-dd_hh-mm-ssa"]; |
| |
| gLoggingDateStamp = [[formatter stringFromDate:[NSDate date]] retain] ; |
| } |
| return gLoggingDateStamp; |
| } |
| |
| + (NSString *)processNameLogPrefix { |
| static NSString *prefix = nil; |
| if (!prefix) { |
| NSString *processName = [[self class] loggingProcessName]; |
| prefix = [[NSString alloc] initWithFormat:@"%@_log_", processName]; |
| } |
| return prefix; |
| } |
| |
| + (NSString *)symlinkNameSuffix { |
| return @"_log_newest.html"; |
| } |
| |
| + (NSString *)htmlFileName { |
| return @"aperçu_http_log.html"; |
| } |
| |
| // formattedStringFromData returns a prettyprinted string for XML or JSON input, |
| // and a plain string for other input data |
| - (NSString *)formattedStringFromData:(NSData *)inputData |
| contentType:(NSString *)contentType |
| JSON:(NSDictionary **)outJSON { |
| if (inputData == nil) 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 = [[self class] JSONObjectWithData:inputData]; |
| 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"]; |
| } |
| } |
| NSString *formatted = [[self class] stringWithJSONObject:obj]; |
| if (formatted) return formatted; |
| } |
| } |
| |
| #if !GTM_FOUNDATION_ONLY && !GTM_SKIP_LOG_XMLFORMAT |
| // verify that this data starts with the bytes indicating XML |
| |
| NSString *const kXMLLintPath = @"/usr/bin/xmllint"; |
| static BOOL hasCheckedAvailability = NO; |
| static BOOL isXMLLintAvailable; |
| |
| if (!hasCheckedAvailability) { |
| isXMLLintAvailable = [[self class] fileOrDirExistsAtPath:kXMLLintPath]; |
| hasCheckedAvailability = YES; |
| } |
| |
| if (isXMLLintAvailable |
| && [inputData length] > 5 |
| && strncmp([inputData bytes], "<?xml", 5) == 0) { |
| |
| // call xmllint to format the data |
| NSTask *task = [[[NSTask alloc] init] autorelease]; |
| [task setLaunchPath:kXMLLintPath]; |
| |
| // use the dash argument to specify stdin as the source file |
| [task setArguments:[NSArray arrayWithObjects:@"--format", @"-", nil]]; |
| [task setEnvironment:[NSDictionary dictionary]]; |
| |
| NSPipe *inputPipe = [NSPipe pipe]; |
| NSPipe *outputPipe = [NSPipe pipe]; |
| [task setStandardInput:inputPipe]; |
| [task setStandardOutput:outputPipe]; |
| |
| [task launch]; |
| |
| [[inputPipe fileHandleForWriting] writeData:inputData]; |
| [[inputPipe fileHandleForWriting] closeFile]; |
| |
| // drain the stdout before waiting for the task to exit |
| NSData *formattedData = [[outputPipe fileHandleForReading] readDataToEndOfFile]; |
| |
| [task waitUntilExit]; |
| |
| int status = [task terminationStatus]; |
| if (status == 0 && [formattedData length] > 0) { |
| // success |
| inputData = formattedData; |
| } |
| } |
| #else |
| // we can't call external tasks on the iPhone; leave the XML unformatted |
| #endif |
| |
| NSString *dataStr = [[[NSString alloc] initWithData:inputData |
| encoding:NSUTF8StringEncoding] autorelease]; |
| return dataStr; |
| } |
| |
| - (void)setupStreamLogging { |
| // if logging is enabled, it needs a buffer to accumulate data from any |
| // NSInputStream used for uploading. Logging will wrap the input |
| // stream with a stream that lets us keep a copy the data being read. |
| if ([GTMHTTPFetcher isLoggingEnabled] |
| && loggedStreamData_ == nil |
| && postStream_ != nil) { |
| loggedStreamData_ = [[NSMutableData alloc] init]; |
| |
| BOOL didCapture = [self logCapturePostStream]; |
| if (!didCapture) { |
| // upload stream logging requires the class |
| // GTMReadMonitorInputStream be available |
| NSString const *str = @"<<Uploaded stream log unavailable without GTMReadMonitorInputStream>>"; |
| [loggedStreamData_ setData:[str dataUsingEncoding:NSUTF8StringEncoding]]; |
| } |
| } |
| } |
| |
| - (void)setLogRequestBody:(NSString *)bodyString { |
| @synchronized(self) { |
| [logRequestBody_ release]; |
| logRequestBody_ = [bodyString copy]; |
| } |
| } |
| |
| - (NSString *)logRequestBody { |
| @synchronized(self) { |
| return logRequestBody_; |
| } |
| } |
| |
| - (void)setLogResponseBody:(NSString *)bodyString { |
| @synchronized(self) { |
| [logResponseBody_ release]; |
| logResponseBody_ = [bodyString copy]; |
| } |
| } |
| |
| - (NSString *)logResponseBody { |
| @synchronized(self) { |
| return logResponseBody_; |
| } |
| } |
| |
| - (void)setShouldDeferResponseBodyLogging:(BOOL)flag { |
| @synchronized(self) { |
| if (flag != shouldDeferResponseBodyLogging_) { |
| shouldDeferResponseBodyLogging_ = flag; |
| if (!flag && !hasLoggedError_) { |
| [self performSelectorOnMainThread:@selector(logFetchWithError:) |
| withObject:nil |
| waitUntilDone:NO]; |
| } |
| } |
| } |
| } |
| |
| - (BOOL)shouldDeferResponseBodyLogging { |
| @synchronized(self) { |
| return shouldDeferResponseBodyLogging_; |
| } |
| } |
| |
| // 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 == nil) 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] autorelease]; |
| if (mungedStr != nil) { |
| |
| // 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 of the munged parts 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]; |
| |
| NSRange range = NSMakeRange(offset, partSize); |
| NSData *origPartData = [data subdataWithRange:range]; |
| |
| NSString *origPartStr = [[[NSString alloc] initWithData:origPartData |
| encoding:NSUTF8StringEncoding] autorelease]; |
| 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; |
| |
| // TODO: (grobbins) add Javascript to display response data formatted in hex |
| |
| NSString *parentDir = [[self class] loggingDirectory]; |
| NSString *processName = [[self class] loggingProcessName]; |
| NSString *dateStamp = [[self class] loggingDateStamp]; |
| |
| NSString *logDirectory = [[self class] logDirectoryForCurrentRun]; |
| if (!logDirectory) return; |
| |
| // 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 run of the app. |
| // |
| // we'll use a local variable since this routine may be reentered while |
| // waiting for XML formatting to be completed by an external task |
| static int zResponseCounter = 0; |
| int responseCounter = ++zResponseCounter; |
| |
| // file name for an image data file |
| NSString *responseDataFileName = nil; |
| NSUInteger responseDataLength; |
| if (downloadFileHandle_) { |
| responseDataLength = (NSUInteger) [downloadFileHandle_ offsetInFile]; |
| } else { |
| responseDataLength = [downloadedData_ length]; |
| } |
| |
| NSURLResponse *response = [self response]; |
| NSDictionary *responseHeaders = [self responseHeaders]; |
| |
| NSString *responseBaseName = nil; |
| 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; |
| NSData *dataToWrite = nil; |
| |
| if (responseDataLength > 0) { |
| NSString *responseDataExtn = nil; |
| |
| // generate a response file base name like |
| responseBaseName = [NSString stringWithFormat:@"httpfetcher_%d_response", |
| responseCounter]; |
| |
| NSString *responseType = [responseHeaders valueForKey:@"Content-Type"]; |
| responseDataStr = [self formattedStringFromData:downloadedData_ |
| contentType:responseType |
| JSON:&responseJSON]; |
| if (responseDataStr) { |
| // we were able to make a UTF-8 string from the response data |
| if ([responseMIMEType isEqual:@"application/atom+xml"] |
| || [responseMIMEType hasSuffix:@"/xml"]) { |
| responseDataExtn = @"xml"; |
| dataToWrite = [responseDataStr dataUsingEncoding:NSUTF8StringEncoding]; |
| } |
| } 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) { |
| 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 |
| BOOL didFileExist = [[self class] fileOrDirExistsAtPath:htmlPath]; |
| |
| NSMutableString* outputHTML = [NSMutableString string]; |
| NSURLRequest *request = [self mutableRequest]; |
| |
| // 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, dateStamp]; |
| } |
| |
| // now write the visible html elements |
| NSString *copyableFileName = [NSString stringWithFormat:@"httpfetcher_%d.txt", |
| responseCounter]; |
| |
| // write the date & time, the comment, and the link to the plain-text |
| // (copyable) log |
| NSString *const dateLineFormat = @"<b>%@ "; |
| [outputHTML appendFormat:dateLineFormat, [NSDate date]]; |
| |
| NSString *comment = [self comment]; |
| if (comment) { |
| NSString *const commentFormat = @"%@ "; |
| [outputHTML appendFormat:commentFormat, comment]; |
| } |
| |
| NSString *const reqRespFormat = @"</b><a href='%@'><i>request/response log</i></a><br>"; |
| [outputHTML appendFormat:reqRespFormat, copyableFileName]; |
| |
| // write the request URL |
| NSString *requestMethod = [request HTTPMethod]; |
| NSURL *requestURL = [request URL]; |
| |
| NSURL *redirectedFromHost = [[[redirectedFromURL_ host] copy] autorelease]; |
| // Save the request URL for next time in case this redirects. |
| [redirectedFromURL_ release]; |
| redirectedFromURL_ = [requestURL copy]; |
| if (redirectedFromHost) { |
| [outputHTML appendFormat:@"<FONT COLOR='#990066'><i>redirected from %@</i></FONT><br>", |
| redirectedFromHost]; |
| } |
| |
| [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) { |
| headerDetails = @" <i>authorized</i>"; |
| BOOL isInsecure = [[requestURL scheme] isEqual:@"http"]; |
| if (isInsecure) { |
| headerDetails = @" <i>authorized, non-SSL</i>" |
| "<FONT COLOR='#FF00FF'> ⚠</FONT> "; // 26A0 = âš |
| } |
| } |
| 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, toggleable |
| NSData *postData; |
| if (loggedStreamData_) { |
| postData = loggedStreamData_; |
| } else if (postData_) { |
| postData = postData_; |
| } else { |
| postData = [request_ HTTPBody]; |
| } |
| |
| NSString *postDataStr = nil; |
| NSUInteger postDataLength = [postData length]; |
| NSString *postType = [requestHeaders valueForKey:@"Content-Type"]; |
| |
| if (postDataLength > 0) { |
| [outputHTML appendFormat:@" data: %d bytes, <code>%@</code><br>\n", |
| (int)postDataLength, postType ? postType : @"<no type>"]; |
| |
| if (logRequestBody_) { |
| postDataStr = [[logRequestBody_ copy] autorelease]; |
| [logRequestBody_ release]; |
| logRequestBody_ = nil; |
| } else { |
| postDataStr = [self stringFromStreamData:postData |
| contentType:postType]; |
| if (postDataStr) { |
| // remove OAuth 2 client secret and refresh token |
| postDataStr = [[self class] snipSubstringOfString:postDataStr |
| betweenStartString:@"client_secret=" |
| endString:@"&"]; |
| |
| postDataStr = [[self class] snipSubstringOfString:postDataStr |
| betweenStartString:@"refresh_token=" |
| endString:@"&"]; |
| |
| // remove ClientLogin password |
| postDataStr = [[self class] snipSubstringOfString:postDataStr |
| 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) { |
| NSString *const jsonErrFmt = @" <i>JSON error:</i> <FONT" |
| @" COLOR='#FF00FF'>%@ %@ ⚑</FONT>"; // 2691 = âš‘ |
| statusString = [statusString stringByAppendingFormat:jsonErrFmt, |
| jsonCode ? jsonCode : @"", |
| jsonMessage ? jsonMessage : @""]; |
| } |
| } |
| } |
| } else { |
| // purple for anything other than 200 or 201 |
| NSString *flag = (status >= 400 ? @" ⚑" : @""); // 2691 = âš‘ |
| NSString *const statusFormat = @"<FONT COLOR='#FF00FF'>%ld %@</FONT>"; |
| statusString = [NSString stringWithFormat:statusFormat, |
| (long)status, 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) { |
| NSString *escapedResponseFile = [responseDataFileName stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]; |
| if (isResponseImage) { |
| // Make a small inline image that links to the full image file |
| [outputHTML appendFormat:@" data: %d bytes, <code>%@</code><br>", |
| (int)responseDataLength, responseMIMEType]; |
| NSString *const fmt = @"<a href=\"%@\"><img src='%@' alt='image'" |
| " style='border:solid thin;max-height:32'></a>\n"; |
| [outputHTML appendFormat:fmt, |
| escapedResponseFile, escapedResponseFile]; |
| } else { |
| // The response data was XML; link to the xml file |
| NSString *const fmt = @" data: %d bytes, <code>" |
| "%@</code> <i><a href=\"%@\">%@</a></i>\n"; |
| [outputHTML appendFormat:fmt, |
| (int)responseDataLength, responseMIMEType, |
| escapedResponseFile, [escapedResponseFile pathExtension]]; |
| } |
| } else { |
| // The response data was not an image; just show the length and MIME type |
| [outputHTML appendFormat:@" data: %d bytes, <code>%@</code>\n", |
| (int)responseDataLength, responseMIMEType]; |
| } |
| |
| // 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:@"%@\n", [NSDate date]]; |
| if (redirectedFromHost) { |
| [copyable appendFormat:@"Redirected from %@\n", redirectedFromHost]; |
| } |
| [copyable appendFormat:@"Request: %@ %@\n", requestMethod, requestURL]; |
| if ([requestHeaders count] > 0) { |
| [copyable appendFormat:@"Request headers:\n%@\n", |
| [[self class] headersStringForDictionary:requestHeaders]]; |
| } |
| |
| if (postDataLength > 0) { |
| [copyable appendFormat:@"Request body: (%u bytes)\n", |
| (unsigned int) postDataLength]; |
| if (postDataStr) { |
| [copyable appendFormat:@"%@\n", postDataStr]; |
| } |
| [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: (%u bytes)\n", |
| (unsigned int) responseDataLength]; |
| if (responseDataLength > 0) { |
| if (logResponseBody_) { |
| responseDataStr = [[logResponseBody_ copy] autorelease]; |
| [logResponseBody_ release]; |
| logResponseBody_ = nil; |
| } |
| if (responseDataStr != nil) { |
| [copyable appendFormat:@"%@\n", responseDataStr]; |
| } else if (status >= 400 && [temporaryDownloadPath_ length] > 0) { |
| // Try to read in the saved data, which is probably a server error |
| // message |
| NSStringEncoding enc; |
| responseDataStr = [NSString stringWithContentsOfFile:temporaryDownloadPath_ |
| usedEncoding:&enc |
| error:NULL]; |
| if ([responseDataStr length] > 0) { |
| [copyable appendFormat:@"%@\n", responseDataStr]; |
| } else { |
| [copyable appendFormat:@"<<%u bytes to file>>\n", |
| (unsigned int) responseDataLength]; |
| } |
| } else { |
| // Even though it's redundant, we'll put in text to indicate that all |
| // the bytes are binary |
| [copyable appendFormat:@"<<%u bytes>>\n", |
| (unsigned int) 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 *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]; |
| |
| [[self class] removeItemAtPath:symlinkPath]; |
| [[self class] createSymbolicLinkAtPath:symlinkPath |
| withDestinationPath:htmlPath]; |
| |
| #if GTM_IPHONE |
| static BOOL gReportedLoggingPath = NO; |
| if (!gReportedLoggingPath) { |
| gReportedLoggingPath = YES; |
| NSLog(@"GTMHTTPFetcher logging to \"%@\"", parentDir); |
| } |
| #endif |
| } |
| } |
| |
| - (BOOL)logCapturePostStream { |
| // This is called when beginning a fetch. The caller should have already |
| // verified that logging is enabled, and should have allocated |
| // loggedStreamData_ as a mutable object. |
| |
| // If the class GTMReadMonitorInputStream is not available, bail now, since |
| // we cannot capture this upload stream |
| Class monitorClass = NSClassFromString(@"GTMReadMonitorInputStream"); |
| if (!monitorClass) return NO; |
| |
| // If we're logging, we need to wrap the upload stream with our monitor |
| // stream that will call us back with the bytes being read from the stream |
| |
| // Our wrapper will retain the old post stream |
| [postStream_ autorelease]; |
| |
| postStream_ = [monitorClass inputStreamWithStream:postStream_]; |
| [postStream_ retain]; |
| |
| [(GTMReadMonitorInputStream *)postStream_ setReadDelegate:self]; |
| [(GTMReadMonitorInputStream *)postStream_ setRunLoopModes:[self runLoopModes]]; |
| |
| SEL readSel = @selector(inputStream:readIntoBuffer:length:); |
| [(GTMReadMonitorInputStream *)postStream_ setReadSelector:readSel]; |
| |
| return YES; |
| } |
| |
| @end |
| |
| @implementation GTMHTTPFetcher (GTMHTTPFetcherLoggingUtilities) |
| |
| - (void)inputStream:(GTMReadMonitorInputStream *)stream |
| readIntoBuffer:(void *)buffer |
| length:(NSUInteger)length { |
| // append the captured data |
| [loggedStreamData_ appendBytes:buffer length:length]; |
| } |
| |
| #pragma mark Internal file routines |
| |
| // We implement plain Unix versions of NSFileManager methods to avoid |
| // NSFileManager's issues with being used from multiple threads |
| |
| + (BOOL)fileOrDirExistsAtPath:(NSString *)path { |
| struct stat buffer; |
| int result = stat([path fileSystemRepresentation], &buffer); |
| return (result == 0); |
| } |
| |
| + (BOOL)makeDirectoryUpToPath:(NSString *)path { |
| int result = 0; |
| |
| // Recursively create the parent directory of the requested path |
| NSString *parent = [path stringByDeletingLastPathComponent]; |
| if (![self fileOrDirExistsAtPath:parent]) { |
| result = [self makeDirectoryUpToPath:parent]; |
| } |
| |
| // Make the leaf directory |
| if (result == 0 && ![self fileOrDirExistsAtPath:path]) { |
| result = mkdir([path fileSystemRepresentation], S_IRWXU); // RWX for owner |
| } |
| return (result == 0); |
| } |
| |
| + (BOOL)removeItemAtPath:(NSString *)path { |
| int result = unlink([path fileSystemRepresentation]); |
| return (result == 0); |
| } |
| |
| + (BOOL)createSymbolicLinkAtPath:(NSString *)newPath |
| withDestinationPath:(NSString *)targetPath { |
| int result = symlink([targetPath fileSystemRepresentation], |
| [newPath fileSystemRepresentation]); |
| return (result == 0); |
| } |
| |
| #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 == nil) 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; |
| } |
| |
| + (id)JSONObjectWithData:(NSData *)data { |
| Class serializer = NSClassFromString(@"NSJSONSerialization"); |
| if (serializer) { |
| const NSUInteger kOpts = (1UL << 0); // NSJSONReadingMutableContainers |
| NSMutableDictionary *obj; |
| obj = [serializer JSONObjectWithData:data |
| options:kOpts |
| error:NULL]; |
| return obj; |
| } else { |
| // Try SBJsonParser or SBJSON |
| Class jsonParseClass = NSClassFromString(@"SBJsonParser"); |
| if (!jsonParseClass) { |
| jsonParseClass = NSClassFromString(@"SBJSON"); |
| } |
| if (jsonParseClass) { |
| GTMFetcherSBJSON *parser = [[[jsonParseClass alloc] init] autorelease]; |
| NSString *jsonStr = [[[NSString alloc] initWithData:data |
| encoding:NSUTF8StringEncoding] autorelease]; |
| if (jsonStr) { |
| NSMutableDictionary *obj = [parser objectWithString:jsonStr error:NULL]; |
| return obj; |
| } |
| } |
| } |
| return nil; |
| } |
| |
| + (id)stringWithJSONObject:(id)obj { |
| Class serializer = NSClassFromString(@"NSJSONSerialization"); |
| if (serializer) { |
| const NSUInteger kOpts = (1UL << 0); // NSJSONWritingPrettyPrinted |
| NSData *data; |
| data = [serializer dataWithJSONObject:obj |
| options:kOpts |
| error:NULL]; |
| if (data) { |
| NSString *jsonStr = [[[NSString alloc] initWithData:data |
| encoding:NSUTF8StringEncoding] autorelease]; |
| return jsonStr; |
| } |
| } else { |
| // Try SBJsonParser or SBJSON |
| Class jsonWriterClass = NSClassFromString(@"SBJsonWriter"); |
| if (!jsonWriterClass) { |
| jsonWriterClass = NSClassFromString(@"SBJSON"); |
| } |
| if (jsonWriterClass) { |
| GTMFetcherSBJSON *writer = [[[jsonWriterClass alloc] init] autorelease]; |
| [writer setHumanReadable:YES]; |
| NSString *jsonStr = [writer stringWithObject:obj error:NULL]; |
| return jsonStr; |
| } |
| } |
| return nil; |
| } |
| |
| @end |
| |
| #endif // !STRIP_GTM_FETCH_LOGGING |