/*
 * Copyright 2010-2014 Haiku Inc. All rights reserved.
 * Distributed under the terms of the MIT License.
 *
 * Authors:
 *		Adrien Destugues, pulkomandy@pulkomandy.tk
 *		Christophe Huriaux, c.huriaux@gmail.com
 *		Hamish Morrison, hamishm53@gmail.com
 */


#include <new>

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#include <Debug.h>
#include <HttpTime.h>
#include <NetworkCookie.h>


using BPrivate::BHttpTime;


static const char* kArchivedCookieName = "be:cookie.name";
static const char* kArchivedCookieValue = "be:cookie.value";
static const char* kArchivedCookieDomain = "be:cookie.domain";
static const char* kArchivedCookiePath = "be:cookie.path";
static const char* kArchivedCookieExpirationDate = "be:cookie.expirationdate";
static const char* kArchivedCookieSecure = "be:cookie.secure";
static const char* kArchivedCookieHttpOnly = "be:cookie.httponly";
static const char* kArchivedCookieHostOnly = "be:cookie.hostonly";


BNetworkCookie::BNetworkCookie(const char* name, const char* value,
	const BUrl& url)
{
	_Reset();
	fName = name;
	fValue = value;

	SetDomain(url.Host());

	if (url.Protocol() == "file" && url.Host().Length() == 0) {
		SetDomain("localhost");
			// make sure cookies set from a file:// URL are stored somewhere.
	}

	SetPath(_DefaultPathForUrl(url));
}


BNetworkCookie::BNetworkCookie(const BString& cookieString, const BUrl& url)
{
	_Reset();
	fInitStatus = ParseCookieString(cookieString, url);
}


BNetworkCookie::BNetworkCookie(BMessage* archive)
{
	_Reset();

	archive->FindString(kArchivedCookieName, &fName);
	archive->FindString(kArchivedCookieValue, &fValue);

	archive->FindString(kArchivedCookieDomain, &fDomain);
	archive->FindString(kArchivedCookiePath, &fPath);
	archive->FindBool(kArchivedCookieSecure, &fSecure);
	archive->FindBool(kArchivedCookieHttpOnly, &fHttpOnly);
	archive->FindBool(kArchivedCookieHostOnly, &fHostOnly);

	// We store the expiration date as a string, which should not overflow.
	// But we still parse the old archive format, where an int32 was used.
	BString expirationString;
	int32 expiration;
	if (archive->FindString(kArchivedCookieExpirationDate, &expirationString)
			== B_OK) {
		BDateTime time = BHttpTime(expirationString).Parse();
		SetExpirationDate(time);
	} else if (archive->FindInt32(kArchivedCookieExpirationDate, &expiration)
			== B_OK) {
		SetExpirationDate((time_t)expiration);
	}
}


BNetworkCookie::BNetworkCookie()
{
	_Reset();
}


BNetworkCookie::~BNetworkCookie()
{
}


// #pragma mark String to cookie fields


status_t
BNetworkCookie::ParseCookieString(const BString& string, const BUrl& url)
{
	_Reset();

	// Set default values (these can be overriden later on)
	SetPath(_DefaultPathForUrl(url));
	SetDomain(url.Host());
	fHostOnly = true;
	if (url.Protocol() == "file" && url.Host().Length() == 0) {
		fDomain = "localhost";
			// make sure cookies set from a file:// URL are stored somewhere.
			// not going through SetDomain as it requires at least one '.'
			// in the domain (to avoid setting cookies on TLDs).
	}

	BString name;
	BString value;
	int32 index = 0;

	// Parse the name and value of the cookie
	index = _ExtractNameValuePair(string, name, value, index);
	if (index == -1 || value.Length() > 4096) {
		// The set-cookie-string is not valid
		return B_BAD_DATA;
	}

	SetName(name);
	SetValue(value);

	// Note on error handling: even if there are parse errors, we will continue
	// and try to parse as much from the cookie as we can.
	status_t result = B_OK;

	// Parse the remaining cookie attributes.
	while (index < string.Length()) {
		ASSERT(string[index] == ';');
		index++;

		index = _ExtractAttributeValuePair(string, name, value, index);

		if (name.ICompare("secure") == 0)
			SetSecure(true);
		else if (name.ICompare("httponly") == 0)
			SetHttpOnly(true);

		// The following attributes require a value.

		if (name.ICompare("max-age") == 0) {
			if (value.IsEmpty()) {
				result = B_BAD_VALUE;
				continue;
			}
			// Validate the max-age value.
			char* end = NULL;
			errno = 0;
			long maxAge = strtol(value.String(), &end, 10);
			if (*end == '\0')
				SetMaxAge((int)maxAge);
			else if (errno == ERANGE && maxAge == LONG_MAX)
				SetMaxAge(INT_MAX);
			else
				SetMaxAge(-1); // cookie will expire immediately
		} else if (name.ICompare("expires") == 0) {
			if (value.IsEmpty()) {
				// Will be a session cookie.
				continue;
			}
			BDateTime parsed = BHttpTime(value).Parse();
			SetExpirationDate(parsed);
		} else if (name.ICompare("domain") == 0) {
			if (value.IsEmpty()) {
				result = B_BAD_VALUE;
				continue;
			}

			status_t domainResult = SetDomain(value);
			// Do not reset the result to B_OK if something else already failed
			if (result == B_OK)
				result = domainResult;
		} else if (name.ICompare("path") == 0) {
			if (value.IsEmpty()) {
				result = B_BAD_VALUE;
				continue;
			}
			status_t pathResult = SetPath(value);
			if (result == B_OK)
				result = pathResult;
		}
	}

	if (!_CanBeSetFromUrl(url))
		result = B_NOT_ALLOWED;

	if (result != B_OK)
		_Reset();

	return result;
}


// #pragma mark Cookie fields modification


BNetworkCookie&
BNetworkCookie::SetName(const BString& name)
{
	fName = name;
	fRawFullCookieValid = false;
	fRawCookieValid = false;
	return *this;
}


BNetworkCookie&
BNetworkCookie::SetValue(const BString& value)
{
	fValue = value;
	fRawFullCookieValid = false;
	fRawCookieValid = false;
	return *this;
}


status_t
BNetworkCookie::SetPath(const BString& to)
{
	fPath.Truncate(0);
	fRawFullCookieValid = false;

	// Limit the path to 4096 characters to not let the cookie jar grow huge.
	if (to[0] != '/' || to.Length() > 4096)
		return B_BAD_DATA;

	// Check that there aren't any "." or ".." segments in the path.
	if (to.EndsWith("/.") || to.EndsWith("/.."))
		return B_BAD_DATA;
	if (to.FindFirst("/../") >= 0 || to.FindFirst("/./") >= 0)
		return B_BAD_DATA;

	fPath = to;
	return B_OK;
}


status_t
BNetworkCookie::SetDomain(const BString& domain)
{
	// TODO: canonicalize the domain
	BString newDomain = domain;

	// RFC 2109 (legacy) support: domain string may start with a dot,
	// meant to indicate the cookie should also be used for subdomains.
	// RFC 6265 makes all cookies work for subdomains, unless the domain is
	// not specified at all (in this case it has to exactly match the Url of
	// the page that set the cookie). In any case, we don't need to handle
	// dot-cookies specifically anymore, so just remove the extra dot.
	if (newDomain[0] == '.')
		newDomain.Remove(0, 1);

	// check we're not trying to set a cookie on a TLD or empty domain
	if (newDomain.FindLast('.') <= 0)
		return B_BAD_DATA;

	fDomain = newDomain.ToLower();

	fHostOnly = false;

	fRawFullCookieValid = false;
	return B_OK;
}


BNetworkCookie&
BNetworkCookie::SetMaxAge(int32 maxAge)
{
	BDateTime expiration = BDateTime::CurrentDateTime(B_LOCAL_TIME);

	// Compute the expiration date (watch out for overflows)
	int64_t date = expiration.Time_t();
	date += (int64_t)maxAge;
	if (date > INT_MAX)
		date = INT_MAX;

	expiration.SetTime_t(date);

	return SetExpirationDate(expiration);
}


BNetworkCookie&
BNetworkCookie::SetExpirationDate(time_t expireDate)
{
	BDateTime expiration;
	expiration.SetTime_t(expireDate);
	return SetExpirationDate(expiration);
}


BNetworkCookie&
BNetworkCookie::SetExpirationDate(BDateTime& expireDate)
{
	if (!expireDate.IsValid()) {
		fExpiration.SetTime_t(0);
		fSessionCookie = true;
	} else {
		fExpiration = expireDate;
		fSessionCookie = false;
	}

	fExpirationStringValid = false;
	fRawFullCookieValid = false;

	return *this;
}


BNetworkCookie&
BNetworkCookie::SetSecure(bool secure)
{
	fSecure = secure;
	fRawFullCookieValid = false;
	return *this;
}


BNetworkCookie&
BNetworkCookie::SetHttpOnly(bool httpOnly)
{
	fHttpOnly = httpOnly;
	fRawFullCookieValid = false;
	return *this;
}


// #pragma mark Cookie fields access


const BString&
BNetworkCookie::Name() const
{
	return fName;
}


const BString&
BNetworkCookie::Value() const
{
	return fValue;
}


const BString&
BNetworkCookie::Domain() const
{
	return fDomain;
}


const BString&
BNetworkCookie::Path() const
{
	return fPath;
}


time_t
BNetworkCookie::ExpirationDate() const
{
	return fExpiration.Time_t();
}


const BString&
BNetworkCookie::ExpirationString() const
{
	BHttpTime date(fExpiration);

	if (!fExpirationStringValid) {
		fExpirationString = date.ToString(BPrivate::B_HTTP_TIME_FORMAT_COOKIE);
		fExpirationStringValid = true;
	}

	return fExpirationString;
}


bool
BNetworkCookie::Secure() const
{
	return fSecure;
}


bool
BNetworkCookie::HttpOnly() const
{
	return fHttpOnly;
}


const BString&
BNetworkCookie::RawCookie(bool full) const
{
	if (!fRawCookieValid) {
		fRawCookie.Truncate(0);
		fRawCookieValid = true;

		fRawCookie << fName << "=" << fValue;
	}

	if (!full)
		return fRawCookie;

	if (!fRawFullCookieValid) {
		fRawFullCookie = fRawCookie;
		fRawFullCookieValid = true;

		if (HasDomain())
			fRawFullCookie << "; Domain=" << fDomain;
		if (HasExpirationDate())
			fRawFullCookie << "; Expires=" << ExpirationString();
		if (HasPath())
			fRawFullCookie << "; Path=" << fPath;
		if (Secure())
			fRawFullCookie << "; Secure";
		if (HttpOnly())
			fRawFullCookie << "; HttpOnly";

	}

	return fRawFullCookie;
}


// #pragma mark Cookie test


bool
BNetworkCookie::IsHostOnly() const
{
	return fHostOnly;
}


bool
BNetworkCookie::IsSessionCookie() const
{
	return fSessionCookie;
}


bool
BNetworkCookie::IsValid() const
{
	return fInitStatus == B_OK && HasName() && HasDomain();
}


bool
BNetworkCookie::IsValidForUrl(const BUrl& url) const
{
	if (Secure() && url.Protocol() != "https")
		return false;

	if (url.Protocol() == "file")
		return Domain() == "localhost" && IsValidForPath(url.Path());

	return IsValidForDomain(url.Host()) && IsValidForPath(url.Path());
}


bool
BNetworkCookie::IsValidForDomain(const BString& domain) const
{
	// TODO: canonicalize both domains
	const BString& cookieDomain = Domain();

	int32 difference = domain.Length() - cookieDomain.Length();
	// If the cookie domain is longer than the domain string it cannot
	// be valid.
	if (difference < 0)
		return false;

	// If the cookie is host-only the domains must match exactly.
	if (IsHostOnly())
		return domain == cookieDomain;

	// FIXME do not do substring matching on IP addresses. The RFCs disallow it.

	// Otherwise, the domains must match exactly, or the domain must have a dot
	// character just before the common suffix.
	const char* suffix = domain.String() + difference;
	return (strcmp(suffix, cookieDomain.String()) == 0 && (difference == 0
		|| domain[difference - 1] == '.'));
}


bool
BNetworkCookie::IsValidForPath(const BString& path) const
{
	const BString& cookiePath = Path();
	BString normalizedPath = path;
	int slashPos = normalizedPath.FindLast('/');
	if (slashPos != normalizedPath.Length() - 1)
		normalizedPath.Truncate(slashPos + 1);

	if (normalizedPath.Length() < cookiePath.Length())
		return false;

	// The cookie path must be a prefix of the path string
	return normalizedPath.Compare(cookiePath, cookiePath.Length()) == 0;
}


bool
BNetworkCookie::_CanBeSetFromUrl(const BUrl& url) const
{
	if (url.Protocol() == "file")
		return Domain() == "localhost" && _CanBeSetFromPath(url.Path());

	return _CanBeSetFromDomain(url.Host()) && _CanBeSetFromPath(url.Path());
}


bool
BNetworkCookie::_CanBeSetFromDomain(const BString& domain) const
{
	// TODO: canonicalize both domains
	const BString& cookieDomain = Domain();

	int32 difference = domain.Length() - cookieDomain.Length();
	if (difference < 0) {
		// Setting a cookie on a subdomain is allowed.
		const char* suffix = cookieDomain.String() + difference;
		return (strcmp(suffix, domain.String()) == 0 && (difference == 0
			|| cookieDomain[difference - 1] == '.'));
	}

	// If the cookie is host-only the domains must match exactly.
	if (IsHostOnly())
		return domain == cookieDomain;

	// FIXME prevent supercookies with a domain of ".com" or similar
	// This is NOT as straightforward as relying on the last dot in the domain.
	// Here's a list of TLD:
	// https://github.com/rsimoes/Mozilla-PublicSuffix/blob/master/effective_tld_names.dat

	// FIXME do not do substring matching on IP addresses. The RFCs disallow it.

	// Otherwise, the domains must match exactly, or the domain must have a dot
	// character just before the common suffix.
	const char* suffix = domain.String() + difference;
	return (strcmp(suffix, cookieDomain.String()) == 0 && (difference == 0
		|| domain[difference - 1] == '.'));
}


bool
BNetworkCookie::_CanBeSetFromPath(const BString& path) const
{
	BString normalizedPath = path;
	int slashPos = normalizedPath.FindLast('/');
	normalizedPath.Truncate(slashPos);

	if (Path().Compare(normalizedPath, normalizedPath.Length()) == 0)
		return true;
	else if (normalizedPath.Compare(Path(), Path().Length()) == 0)
		return true;
	return false;
}


// #pragma mark Cookie fields existence tests


bool
BNetworkCookie::HasName() const
{
	return fName.Length() > 0;
}


bool
BNetworkCookie::HasValue() const
{
	return fValue.Length() > 0;
}


bool
BNetworkCookie::HasDomain() const
{
	return fDomain.Length() > 0;
}


bool
BNetworkCookie::HasPath() const
{
	return fPath.Length() > 0;
}


bool
BNetworkCookie::HasExpirationDate() const
{
	return !IsSessionCookie();
}


// #pragma mark Cookie delete test


bool
BNetworkCookie::ShouldDeleteAtExit() const
{
	return IsSessionCookie() || ShouldDeleteNow();
}


bool
BNetworkCookie::ShouldDeleteNow() const
{
	if (HasExpirationDate())
		return (BDateTime::CurrentDateTime(B_GMT_TIME) > fExpiration);

	return false;
}


// #pragma mark BArchivable members


status_t
BNetworkCookie::Archive(BMessage* into, bool deep) const
{
	status_t error = BArchivable::Archive(into, deep);

	if (error != B_OK)
		return error;

	error = into->AddString(kArchivedCookieName, fName);
	if (error != B_OK)
		return error;

	error = into->AddString(kArchivedCookieValue, fValue);
	if (error != B_OK)
		return error;


	// We add optional fields only if they're defined
	if (HasDomain()) {
		error = into->AddString(kArchivedCookieDomain, fDomain);
		if (error != B_OK)
			return error;
	}

	if (HasExpirationDate()) {
		error = into->AddString(kArchivedCookieExpirationDate,
			BHttpTime(fExpiration).ToString());
		if (error != B_OK)
			return error;
	}

	if (HasPath()) {
		error = into->AddString(kArchivedCookiePath, fPath);
		if (error != B_OK)
			return error;
	}

	if (Secure()) {
		error = into->AddBool(kArchivedCookieSecure, fSecure);
		if (error != B_OK)
			return error;
	}

	if (HttpOnly()) {
		error = into->AddBool(kArchivedCookieHttpOnly, fHttpOnly);
		if (error != B_OK)
			return error;
	}

	if (IsHostOnly()) {
		error = into->AddBool(kArchivedCookieHostOnly, true);
		if (error != B_OK)
			return error;
	}

	return B_OK;
}


/*static*/ BArchivable*
BNetworkCookie::Instantiate(BMessage* archive)
{
	if (archive->HasString(kArchivedCookieName)
		&& archive->HasString(kArchivedCookieValue))
		return new(std::nothrow) BNetworkCookie(archive);

	return NULL;
}


// #pragma mark Overloaded operators


bool
BNetworkCookie::operator==(const BNetworkCookie& other)
{
	// Equality : name and values equals
	return fName == other.fName && fValue == other.fValue;
}


bool
BNetworkCookie::operator!=(const BNetworkCookie& other)
{
	return !(*this == other);
}


void
BNetworkCookie::_Reset()
{
	fInitStatus = false;

	fName.Truncate(0);
	fValue.Truncate(0);
	fDomain.Truncate(0);
	fPath.Truncate(0);
	fExpiration = BDateTime();
	fSecure = false;
	fHttpOnly = false;

	fSessionCookie = true;
	fHostOnly = true;

	fRawCookieValid = false;
	fRawFullCookieValid = false;
	fExpirationStringValid = false;
}


int32
skip_whitespace_forward(const BString& string, int32 index)
{
	while (index < string.Length() && (string[index] == ' '
			|| string[index] == '\t'))
		index++;
	return index;
}


int32
skip_whitespace_backward(const BString& string, int32 index)
{
	while (index >= 0 && (string[index] == ' ' || string[index] == '\t'))
		index--;
	return index;
}


int32
BNetworkCookie::_ExtractNameValuePair(const BString& cookieString,
	BString& name, BString& value, int32 index)
{
	// Find our name-value-pair and the delimiter.
	int32 firstEquals = cookieString.FindFirst('=', index);
	int32 nameValueEnd = cookieString.FindFirst(';', index);

	// If the set-cookie-string lacks a semicolon, the name-value-pair
	// is the whole string.
	if (nameValueEnd == -1)
		nameValueEnd = cookieString.Length();

	// If the name-value-pair lacks an equals, the parse should fail.
	if (firstEquals == -1 || firstEquals > nameValueEnd)
		return -1;

	int32 first = skip_whitespace_forward(cookieString, index);
	int32 last = skip_whitespace_backward(cookieString, firstEquals - 1);

	// If we lack a name, fail to parse.
	if (first > last)
		return -1;

	cookieString.CopyInto(name, first, last - first + 1);

	first = skip_whitespace_forward(cookieString, firstEquals + 1);
	last = skip_whitespace_backward(cookieString, nameValueEnd - 1);
	if (first <= last)
		cookieString.CopyInto(value, first, last - first + 1);
	else
		value.SetTo("");

	return nameValueEnd;
}


int32
BNetworkCookie::_ExtractAttributeValuePair(const BString& cookieString,
	BString& attribute, BString& value, int32 index)
{
	// Find the end of our cookie-av.
	int32 cookieAVEnd = cookieString.FindFirst(';', index);

	// If the unparsed-attributes lacks a semicolon, then the cookie-av is the
	// whole string.
	if (cookieAVEnd == -1)
		cookieAVEnd = cookieString.Length();

	int32 attributeNameEnd = cookieString.FindFirst('=', index);
	// If the cookie-av has no equals, the attribute-name is the entire
	// cookie-av and the attribute-value is empty.
	if (attributeNameEnd == -1 || attributeNameEnd > cookieAVEnd)
		attributeNameEnd = cookieAVEnd;

	int32 first = skip_whitespace_forward(cookieString, index);
	int32 last = skip_whitespace_backward(cookieString, attributeNameEnd - 1);

	if (first <= last)
		cookieString.CopyInto(attribute, first, last - first + 1);
	else
		attribute.SetTo("");

	if (attributeNameEnd == cookieAVEnd) {
		value.SetTo("");
		return cookieAVEnd;
	}

	first = skip_whitespace_forward(cookieString, attributeNameEnd + 1);
	last = skip_whitespace_backward(cookieString, cookieAVEnd - 1);
	if (first <= last)
		cookieString.CopyInto(value, first, last - first + 1);
	else
		value.SetTo("");

	// values may (or may not) have quotes around them.
	if (value[0] == '"' && value[value.Length() - 1] == '"') {
		value.Remove(0, 1);
		value.Remove(value.Length() - 1, 1);
	}

	return cookieAVEnd;
}


BString
BNetworkCookie::_DefaultPathForUrl(const BUrl& url)
{
	const BString& path = url.Path();
	if (path.IsEmpty() || path.ByteAt(0) != '/')
		return "";

	int32 index = path.FindLast('/');
	if (index == 0)
		return "";

	BString newPath = path;
	newPath.Truncate(index);
	return newPath;
}
