// Copyright 1997-1999 Omni Development, Inc.  All rights reserved.
//
// This software may only be used and reproduced according to the
// terms in the file OmniSourceLicense.html, which should be
// distributed with this project and can also be found at
// http://www.omnigroup.com/DeveloperResources/OmniSourceLicense.html.

#import "OWCookie.h"

#import <Foundation/Foundation.h>
#import <OmniBase/OmniBase.h>
#import <OmniFoundation/OmniFoundation.h>

#import "NSDate-OWExtensions.h"
#import "OWAddress.h"
#import "OWContentCache.h"
#import "OWHeaderDictionary.h"
#import "OWHTTPProcessor.h"
#import "OWHTTPSession.h"
#import "OWNetLocation.h"
#import "OWURL.h"

RCS_ID("$Header: /Network/Source/CVS/OmniGroup/Frameworks/OWF/Processors.subproj/Protocols.subproj/HTTP.subproj/OWCookie.m,v 1.25 1999/07/21 04:01:56 kc Exp $")

@interface OWCookie (Private)
+ (NSString *)cookieFilename;
+ (NSString *)oldCookieFilename;
+ (BOOL)readCookiesFromFile:(NSString *)cookieFilename;

- initWithDomain:(NSString *)aDomain path:(NSString *)aPath name:(NSString *)aName value:(NSString *)aValue expirationDate:(NSDate *)aDate secure:(BOOL)isSecure;
- initToImmunizeDomain:(NSString *)aDomain;
@end

@implementation OWCookie

static NSLock *registeredCookiesLock;
static NSMutableDictionary *registeredCookies;
static unsigned int registeredCookiesChangeCount;
static unsigned int lastSavedChangeCount;

static NSArray *shortTopLevelDomains;
static NSCharacterSet *endNameSet, *endValueSet, *endDateSet, *endKeySet;

// We will be phasing away from OWCookieExpirationDate (ie, a formatted date) towards OWCookieExpirationTimeInterval since it is easier and faster to parse timeIntervals
static NSString *OWCookieExpirationTimeInterval = @"expirationTime";
static NSString *OWCookieExpirationDate = @"expirationDate";

static BOOL debugCookies = NO;

+ (void)initialize;
{
    static BOOL initialized = NO;

    [super initialize];
    if (initialized)
	return;
    initialized = YES;

    registeredCookiesLock = [[NSRecursiveLock alloc] init];
    registeredCookies = nil;
    registeredCookiesChangeCount = 0;
    lastSavedChangeCount = 0;

    shortTopLevelDomains = nil;
    endNameSet = [[NSCharacterSet characterSetWithCharactersInString:@"=;, \t\r\n"] retain];
    endDateSet = [[NSCharacterSet characterSetWithCharactersInString:@";\r\n"] retain];
    endValueSet = [[NSCharacterSet characterSetWithCharactersInString:@";, \t\r\n"] retain];
    endKeySet = [[NSCharacterSet characterSetWithCharactersInString:@"=;, \t\r\n"] retain];
}

+ (void)didLoad;
{
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(controllerDidInit:) name:OFControllerDidInitNotification object:nil];
}

+ (void)controllerDidInit:(NSNotification *)notification;
{
    [self readDefaults];
}

+ (void)readDefaults;
{
    OFUserDefaults *userDefaults;

    userDefaults = [OFUserDefaults sharedUserDefaults];
    shortTopLevelDomains = [userDefaults arrayForKey:@"OWShortTopLevelDomains"];
    
    // Read the cookies
    [registeredCookiesLock lock];
    if (![self readCookiesFromFile:[self cookieFilename]])
        [self readCookiesFromFile:[self oldCookieFilename]];
    lastSavedChangeCount = registeredCookiesChangeCount;
    [registeredCookiesLock unlock];
}

+ (void)deleteCookies;
{
    [registeredCookiesLock lock];
    [registeredCookies release];
    registeredCookies = nil;
    [[NSFileManager defaultManager] removeFileAtPath:[self cookieFilename] handler:nil];
    [registeredCookiesLock unlock];
}

+ (void)saveCookies;
{
    NSString *cookieFilename;
    
    if (!registeredCookies)
        return;

    [registeredCookiesLock lock];
    if (lastSavedChangeCount == registeredCookiesChangeCount) {
        [registeredCookiesLock unlock];
        return;
    }
    lastSavedChangeCount = registeredCookiesChangeCount;
    if ((cookieFilename = [self cookieFilename])) {
        [registeredCookies writeToFile:cookieFilename atomically:YES];
    }
    [registeredCookiesLock unlock];
}

+ (NSDictionary *)registeredCookies;
{
    return registeredCookies;
}

+ (NSArray *)searchDomainsForDomain:(NSString *)aDomain;
{
    NSMutableArray *searchDomains;
    NSMutableArray *domainComponents;
    unsigned int domainComponentCount;
    unsigned int minimumDomainComponents;

    domainComponents = [[aDomain componentsSeparatedByString:@"."] mutableCopy];
    domainComponentCount = [domainComponents count];
    minimumDomainComponents = [shortTopLevelDomains containsObject:[domainComponents lastObject]] ? 2 : 3;
    searchDomains = [NSMutableArray arrayWithCapacity:domainComponentCount];
    [searchDomains addObject:aDomain];
    if (domainComponentCount < minimumDomainComponents) {
        [domainComponents release];
	return searchDomains;
    }
    domainComponentCount -= minimumDomainComponents;
    while (domainComponentCount--) {
	NSString *searchDomain;

	[domainComponents removeObjectAtIndex:0];
	searchDomain = [domainComponents componentsJoinedByString:@"."];
	[searchDomains addObject:[@"." stringByAppendingString:searchDomain]];
	[searchDomains addObject:searchDomain];
    }
    [domainComponents release];
    return searchDomains;
}

+ (NSArray *)searchPathsForPath:(NSString *)aPath;
{
    NSMutableArray *searchPaths;
    NSArray *pathComponents;
    unsigned int pathComponentIndex, pathComponentCount;

    if (!aPath || [aPath length] == 0 || [aPath isEqualToString:@"/"])
	return [NSArray arrayWithObject:@"/"];
    pathComponents = [OWURL pathComponentsForPath:aPath];
    pathComponentCount = [pathComponents count];
    searchPaths = [NSMutableArray arrayWithCapacity:pathComponentCount];
    pathComponentIndex = pathComponentCount;
    for (pathComponentIndex = pathComponentCount; pathComponentIndex > 0; pathComponentIndex--) {
        if (pathComponentIndex < pathComponentCount && ![aPath hasSuffix:@"/"]) {
            // For /foo/bar,  look in (/foo/bar, /foo/, /foo, /)
            [searchPaths addObject:[aPath stringByAppendingString:@"/"]];
        }
        [searchPaths addObject:aPath];
        aPath = [OWURL stringByDeletingLastPathComponentFromPath:aPath];
    }
    if ([aPath length] > 1)
        [searchPaths addObject:[aPath stringByAppendingString:@"/"]];
    return searchPaths;
}

+ (NSArray *)cookiesForDomain:(NSString *)aDomain path:(NSString *)aPath;
{
    NSMutableArray *cookies;
    NSArray *searchDomains, *searchPaths, *searchCookies;
    unsigned int domainIndex, domainCount;
    unsigned int pathIndex, pathCount;
    unsigned int cookieIndex, cookieCount;
    NSString *searchDomain, *searchPath;
    OWCookie *aCookie;

    if (!registeredCookies || !aDomain)
	return nil;

    searchDomains = [self searchDomainsForDomain:[aDomain lowercaseString]];
    searchPaths = [self searchPathsForPath:aPath];

    if (debugCookies)
        NSLog(@"domain=%@, path=%@ --> domains=%@, paths=%@", aDomain, aPath, searchDomains, searchPaths);

    pathCount = [searchPaths count];
    cookies = [NSMutableArray array];
    [registeredCookiesLock lock];
    domainCount = [searchDomains count];
    for (domainIndex = 0; domainIndex < domainCount; domainIndex++) {
	searchDomain = [searchDomains objectAtIndex:domainIndex];
	for (pathIndex = 0; pathIndex < pathCount; pathIndex++) {
	    searchPath = [searchPaths objectAtIndex:pathIndex];
	    searchCookies = [[[registeredCookies objectForKey:searchDomain] objectForKey:searchPath] allValues];
	    cookieCount = [searchCookies count];
	    for (cookieIndex = 0; cookieIndex < cookieCount; cookieIndex++) {
	        aCookie = [searchCookies objectAtIndex:cookieIndex];
                if ([aCookie isExpired]) {
                    if (debugCookies)
                        NSLog(@"Expired cookie: %@", aCookie);
                    [[[registeredCookies objectForKey:searchDomain] objectForKey:searchPath] removeObjectForKey:[aCookie name]];
                    continue;
                }
		if (![[aCookie path] isEqualToString:OWCookieImmunizedDomainPath])
                    [cookies addObject:aCookie];
	    }
	}
    }
    [registeredCookiesLock unlock];
    return [cookies count] > 0 ? cookies : nil;
}

+ (NSArray *)cookiesForURL:(OWURL *)url;
{
    NSArray *cookies;

    cookies = [self cookiesForDomain:[[url parsedNetLocation] hostname] path:[@"/" stringByAppendingString:[url path]]];

    if (debugCookies)
        NSLog(@"%@ --> cookies %@", [url shortDescription], cookies);

    return cookies;
}

+ (void)registerCookiesFromURL:(OWURL *)url headerDictionary:(OWHeaderDictionary *)headerDictionary;
{
    NSString *defaultDomain, *defaultPath;
    NSArray *valueArray;
    unsigned int valueIndex, valueCount;

    if (!url)
        return;

    valueArray = [headerDictionary stringArrayForKey:@"set-cookie"];

    defaultDomain = [[url parsedNetLocation] hostname];
    defaultPath = [@"/" stringByAppendingString:[url path]];
#if 0
    // This is incorrect, but since our search algorithm is also incorrect, I think this might produce better behavior
    if ([defaultPath length] > 1)
        defaultPath = [OWURL stringByDeletingLastPathComponentFromPath:defaultPath];
// #else
    // It's unclear exactly what we're supposed to use for a default path. This seems to follow the only document that specifies any actual behavior. I'm deviating from the specified behavior in cases which I think they forgot to consider.

    defaultPathComponents = [OWURL pathComponentsForPath:defaultPath];
    if ([defaultPathComponents count] /* &&
        [[defaultPathComponents lastObject] length] > 0 */ ) {
        defaultPathComponents = [defaultPathComponents mutableCopy];
        [defaultPathComponents removeLastObject];
        if ([defaultPathComponents count] < 2)
            defaultPath = @"/";
        else
            defaultPath = [defaultPathComponents componentsJoinedByString:@"/"];
    }
#else
    // this is *also* incorrect, in a different way
    {
        NSRange slashRange = [defaultPath rangeOfString:@"/" options:NSBackwardsSearch];
        if (slashRange.length != 0 && slashRange.location > 0) {
            defaultPath = [defaultPath substringToIndex:slashRange.location];
        }
    }
#endif

    OBASSERT(defaultDomain != nil);

    if (debugCookies)
        NSLog(@"%@ --> domain=%@, path=%@", [url shortDescription], defaultDomain, defaultPath);

    if (!valueArray)
	return;

    valueCount = [valueArray count];
    for (valueIndex = 0; valueIndex < valueCount; valueIndex++) {
        NSString *headerValue;
        OWCookie *cookie;
        
        headerValue = [valueArray objectAtIndex:valueIndex];
        cookie = [self cookieFromHeaderValue:headerValue defaultDomain:defaultDomain defaultPath:defaultPath];
        [cookie registerCookie];
    }
}

+ (OWCookie *)cookieWithDomain:(NSString *)aDomain path:(NSString *)aPath name:(NSString *)aName dictionary:(NSDictionary *)aDictionary;
{
    BOOL isSecure;
    NSDate *date;
    NSString *expirationString;

    isSecure = [[aDictionary objectForKey:@"secure"] isEqualToString:@"YES"];

    if ((expirationString = [aDictionary objectForKey:OWCookieExpirationTimeInterval])) {
        NSTimeInterval timeInterval;

        timeInterval = [expirationString doubleValue];
        date = [NSCalendarDate dateWithTimeIntervalSinceReferenceDate:timeInterval];
    } else if ((expirationString = [aDictionary objectForKey:OWCookieExpirationDate])) {
        date = [NSDate dateWithHTTPDateString:expirationString];
    } else {
        // Cookies with no specified expiration were supposed to have expired before being saved.  (Unless, of course, they're the virtual cookies which indicate an immunized domain.)
        if (![aPath isEqualToString:OWCookieImmunizedDomainPath])
            return nil;
        // OK, we have an immunized domain cookie, we'll need to register it.
        date = nil;
    }

    return [OWCookie cookieWithDomain:aDomain path:aPath name:aName value:[aDictionary objectForKey:@"value"] expirationDate:date secure:isSecure];
}

+ (OWCookie *)cookieWithDomain:(NSString *)aDomain path:(NSString *)aPath name:(NSString *)aName value:(NSString *)aValue expirationDate:(NSDate *)aDate secure:(BOOL)isSecure;
{
    NSDictionary *domainDict;

    if ([[OFUserDefaults sharedUserDefaults] boolForKey:@"OWHTTPRefuseAllCookies"])
        return nil;

    aDomain = [aDomain lowercaseString];
    domainDict = [registeredCookies objectForKey:aDomain];
    if (domainDict && [domainDict objectForKey:OWCookieImmunizedDomainPath]) {
        // This domain is immune to cookies
        return nil;
    }

    return [[[self alloc] initWithDomain:aDomain path:aPath name:aName value:aValue expirationDate:aDate secure:isSecure] autorelease];
}

+ (OWCookie *)cookieFromHeaderValue:(NSString *)headerValue defaultDomain:(NSString *)defaultDomain defaultPath:(NSString *)defaultPath;
{
    NSString *aName, *aValue;
    NSDate *aDate = nil;
    NSString *aDomain = defaultDomain, *aPath = defaultPath;
    BOOL isSecure = NO;
    NSScanner *scanner;
    NSString *aKey;

    scanner = [NSScanner scannerWithString:headerValue];
    if (![scanner scanUpToCharactersFromSet:endNameSet intoString:&aName] ||
	![scanner scanString:@"=" intoString:NULL] ||
	![scanner scanUpToCharactersFromSet:endValueSet intoString:&aValue])
	return nil;
    [scanner scanCharactersFromSet:endKeySet intoString:NULL];
    while ([scanner scanUpToCharactersFromSet:endKeySet intoString:&aKey]) {
	aKey = [aKey lowercaseString];
	[scanner scanString:@"=" intoString:NULL];
	if ([aKey isEqualToString:@"expires"]) {
	    NSString *dateString = nil;

	    [scanner scanUpToCharactersFromSet:endDateSet intoString:&dateString];
            if (dateString) {
                aDate = [NSDate dateWithHTTPDateString:dateString];
                if (!aDate) {
                    NSCalendarDate *yearFromNowDate;

                    NSLog(@"OWCookie: could not parse expiration date, expiring cookie in one year");
                    yearFromNowDate = [[NSCalendarDate calendarDate] dateByAddingYears:1 months:0 days:0 hours:0 minutes:0 seconds:0];
                    [yearFromNowDate setCalendarFormat:[OWHTTPSession preferredDateFormat]];
                    aDate = yearFromNowDate;
                }
            }
	} else if ([aKey isEqualToString:@"domain"]) {
	    [scanner scanUpToCharactersFromSet:endValueSet intoString:&aDomain];
	} else if ([aKey isEqualToString:@"path"]) {
	    [scanner scanUpToCharactersFromSet:endValueSet intoString:&aPath];
	} else if ([aKey isEqualToString:@"secure"]) {
	    isSecure = YES;
	}
	[scanner scanCharactersFromSet:endKeySet intoString:NULL];
    }
    return [self cookieWithDomain:aDomain path:aPath name:aName value:aValue expirationDate:aDate secure:isSecure];
}

+ (void)deleteDomain:(NSString *)aDomain path:(NSString *)aPath cookie:(OWCookie *)aCookie;
{
    NSMutableDictionary *domainDict;
    NSMutableDictionary *pathDict;

    aDomain = [aDomain lowercaseString];
    domainDict = [registeredCookies objectForKey:aDomain];
    if (!domainDict)
        return;

    registeredCookiesChangeCount++;
    if (aPath) {
        pathDict = [domainDict objectForKey:aPath];
        if (pathDict) {
            if (aCookie)
                [pathDict removeObjectForKey:[aCookie name]];
            else
                [domainDict removeObjectForKey:aPath];
        }
    } else {
        [registeredCookies removeObjectForKey:aDomain];
    }
}

+ (void)disableDomain:(NSString *)aDomain;
{
    NSMutableDictionary *domainDict;
    OWCookie *immunizer;

    aDomain = [aDomain lowercaseString];
    domainDict = [registeredCookies objectForKey:aDomain];
    if ([domainDict objectForKey:OWCookieImmunizedDomainPath])
        return;

    immunizer = [[self alloc] initToImmunizeDomain:aDomain];
    [immunizer registerCookie];
    [immunizer release];
}

// Init and dealloc

- (void)dealloc;
{
    [domain release];
    [path release];
    [name release];
    [value release];
    [expirationDate release];
    [super dealloc];
}

// API

- (NSString *)domain;
{
    return domain;
}

- (NSString *)path;
{
    return path;
}

- (NSString *)name;
{
    return name;
}

- (NSString *)value;
{
    return value;
}

- (NSDate *)expirationDate;
{
    return expirationDate;
}

- (BOOL)isExpired;
{
    return expirationDate != nil && [expirationDate timeIntervalSinceNow] < 0.0;
}

- (BOOL)secure;
{
    return secure;
}

- (BOOL)appliesToAddress:(id <OWAddress>)anAddress;
{
    if ([self isExpired])
        return NO;

    if ([anAddress isKindOfClass:[OWAddress class]]) {
        OWURL *url;
        OWNetLocation *netLocation;

        url = [(OWAddress *)anAddress url];
        netLocation = [url parsedNetLocation];
        if (netLocation != nil && [[netLocation hostname] hasSuffix:domain]) {
            NSString *fetchPath;

            fetchPath = [url fetchPath];
            if (fetchPath != nil && [fetchPath hasPrefix:path])
                return YES;
        }
    }
    return NO;
}

- (void)registerCookie;
{
    NSMutableDictionary *pathDictionary, *cookieDictionary;

    [registeredCookiesLock lock];
    if (!registeredCookies)
	registeredCookies = [[NSMutableDictionary alloc] initWithCapacity:1];
    pathDictionary = [registeredCookies objectForKey:domain];
    if (!pathDictionary) {
	pathDictionary = [NSMutableDictionary dictionaryWithCapacity:1];
	[registeredCookies setObject:pathDictionary forKey:domain];
    }
    cookieDictionary = [pathDictionary objectForKey:path];
    if (!cookieDictionary) {
	cookieDictionary = [NSMutableDictionary dictionaryWithCapacity:1];
	[pathDictionary setObject:cookieDictionary forKey:path];
    }
    [cookieDictionary setObject:self forKey:name];
    registeredCookiesChangeCount++;
    [isa queueSelectorOnce:@selector(saveCookies)];
    [registeredCookiesLock unlock];
    [OWContentCache flushCachedContentMatchingCookie:self];
}

- (NSMutableDictionary *)saveDictionary;
{
    NSMutableDictionary *saveDictionary;

    if (!expirationDate) {
        // This cookie shouldn't even be here.  For now, return an empty cookie.
        return [NSMutableDictionary dictionary];
    }

    saveDictionary = [NSMutableDictionary dictionaryWithCapacity:4];

    // For right now, we will write the date as BOTH a formatted date and a time interval, allowing OmniWeb 2.x to read cookies written by OmniWeb 3.x
    [saveDictionary setObject:[expirationDate description] forKey:OWCookieExpirationDate];
    [saveDictionary setObject:[NSString stringWithFormat:@"%.0lf", [expirationDate timeIntervalSinceReferenceDate]] forKey:OWCookieExpirationTimeInterval];

    if (value)
	[saveDictionary setObject:value forKey:@"value"];

    if (secure)
	[saveDictionary setObject:@"YES" forKey:@"secure"];

    return saveDictionary;
}

- (NSString *)descriptionWithLocale:(NSDictionary *)locale indent:(unsigned int)indentLevel;
{
    return [[self saveDictionary] descriptionWithLocale:nil indent:indentLevel];
}

@end


@implementation OWCookie (Private)

+ (NSString *)cookieFilename;
{
    NSString *directory;
    NSFileManager *fileManager;
    BOOL isDirectory;

    directory = [[[OFUserDefaults sharedUserDefaults] objectForKey:@"OWLibraryDirectory"] stringByStandardizingPath];
    fileManager = [NSFileManager defaultManager];
    if (![fileManager fileExistsAtPath:directory isDirectory:&isDirectory] || !isDirectory)
        return nil;

    return [directory stringByAppendingPathComponent:@"Cookies.plist"];
}

+ (NSString *)oldCookieFilename;
{
    NSString *directory;

    directory = [[[OFUserDefaults sharedUserDefaults] objectForKey:@"OWLibraryDirectory"] stringByStandardizingPath];
    return [directory stringByAppendingPathComponent:@"Cookies"];
}

+ (BOOL)readCookiesFromFile:(NSString *)cookieFilename;
{
    NSMutableDictionary *registrationDict;
    NSEnumerator *domainEnumerator;
    NSString *aDomain;
    BOOL success;

    if (!cookieFilename)
        return NO;

    NS_DURING {
        registrationDict = [[NSMutableDictionary alloc] initWithContentsOfFile:cookieFilename];
        domainEnumerator = [registrationDict keyEnumerator];
        while ((aDomain = [domainEnumerator nextObject])) {
            NSString *aPath;
            NSEnumerator *pathEnumerator;
            NSMutableDictionary *domainDict;

            domainDict = [registrationDict objectForKey:aDomain];
            pathEnumerator = [domainDict keyEnumerator];
            while ((aPath = [pathEnumerator nextObject])) {
                NSString *aName;
                NSEnumerator *nameEnumerator;
                NSMutableDictionary *pathDict;

                pathDict = [domainDict objectForKey:aPath];
                nameEnumerator = [pathDict keyEnumerator];
                while ((aName = [nameEnumerator nextObject]))
                    [[self cookieWithDomain:aDomain path:aPath name:aName dictionary:[pathDict objectForKey:aName]] registerCookie];
            }
        }
        success = YES;
    } NS_HANDLER {
        // No saved cookies
        success = NO;
    } NS_ENDHANDLER;
    [registrationDict release];
    return success;
}

//

- initWithDomain:(NSString *)aDomain path:(NSString *)aPath name:(NSString *)aName value:(NSString *)aValue expirationDate:(NSDate *)aDate secure:(BOOL)isSecure;
{
    if ([aPath isEqualToString:OWCookieImmunizedDomainPath] && (![aName isEqualToString:@"==immune=="] || aValue || aDate)) {
        // Immunized domain
        [self release];
        return nil;
    }

    OBASSERT(aDomain != nil);
    if (!aDomain) {
        // Not sure how this got here, but let's not make anything worse.
        [self release];
        return nil;
    }

    if (![super init])
        return nil;

    domain = [[aDomain lowercaseString] retain];
    path = [aPath retain];
    name = [aName retain];
    value = [aValue retain];
    expirationDate = [aDate retain];
    secure = isSecure;

    return self;
}

- initToImmunizeDomain:(NSString *)aDomain
{
    return [self initWithDomain:aDomain path:OWCookieImmunizedDomainPath name:@"==immune==" value:nil expirationDate:nil secure:NO];
}



// Debugging methods

- (NSMutableDictionary *)debugDictionary;
{
    NSMutableDictionary *debugDictionary;

    debugDictionary = [super debugDictionary];

    if (domain)
	[debugDictionary setObject:domain forKey:@"domain"];
    if (path)
	[debugDictionary setObject:path forKey:@"path"];
    if (name)
	[debugDictionary setObject:name forKey:@"name"];
    if (value)
	[debugDictionary setObject:value forKey:@"value"];
    if (expirationDate)
        [debugDictionary setObject:expirationDate forKey:OWCookieExpirationDate];
    [debugDictionary setObject:secure ? @"YES" : @"NO" forKey:@"secure"];

    return debugDictionary;
}

@end

NSString *OWCookieImmunizedDomainPath = @"(Immunized Domain)";
