diff --git a/datetime/mod.ts b/datetime/mod.ts index a5c2648b02ead6..4d627fcbeedaf2 100644 --- a/datetime/mod.ts +++ b/datetime/mod.ts @@ -1,4 +1,6 @@ // Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +import { pad } from "../strings/pad.ts"; + export type DateFormat = "mm-dd-yyyy" | "dd-mm-yyyy" | "yyyy-mm-dd"; /** @@ -105,3 +107,40 @@ export function dayOfYear(date: Date): number { export function currentDayOfYear(): number { return dayOfYear(new Date()); } + +/** + * Parse a date to return a IMF formated string date + * RFC: https://tools.ietf.org/html/rfc7231#section-7.1.1.1 + * IMF is the time format to use when generating times in HTTP + * headers. The time being formatted must be in UTC for Format to + * generate the correct format. + * @param date Date to parse + * @return IMF date formated string + */ +export function toIMF(date: Date): string { + function dtPad(v: string, lPad: number = 2): string { + return pad(v, lPad, { char: "0" }); + } + const d = dtPad(date.getUTCDate().toString()); + const h = dtPad(date.getUTCHours().toString()); + const min = dtPad(date.getUTCMinutes().toString()); + const s = dtPad(date.getUTCSeconds().toString()); + const y = date.getUTCFullYear(); + const days = ["Sun", "Mon", "Tue", "Wed", "Thus", "Fri", "Sat"]; + const months = [ + "Jan", + "Feb", + "Mar", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ]; + return `${days[date.getDay()]}, ${d} ${ + months[date.getUTCMonth()] + } ${y} ${h}:${min}:${s} GMT`; +} diff --git a/datetime/test.ts b/datetime/test.ts index 95bcd36533e4cf..f4791472849d44 100644 --- a/datetime/test.ts +++ b/datetime/test.ts @@ -74,3 +74,12 @@ test(function DayOfYear(): void { test(function currentDayOfYear(): void { assertEquals(datetime.currentDayOfYear(), datetime.dayOfYear(new Date())); }); + +test({ + name: "[DateTime] to IMF", + fn(): void { + const actual = datetime.toIMF(new Date(Date.UTC(1994, 3, 5, 15, 32))); + const expected = "Tue, 05 May 1994 15:32:00 GMT"; + assertEquals(actual, expected); + } +}); diff --git a/http/README.md b/http/README.md index 448692a6879dc1..c7bac3f093b333 100644 --- a/http/README.md +++ b/http/README.md @@ -2,6 +2,45 @@ A framework for creating HTTP/HTTPS server. +## Cookie + +Helper to manipulate `Cookie` throught `ServerRequest` and `Response`. + +```ts +import { getCookies } from "https://deno.land/std/http/cookie.ts"; + +let req = new ServerRequest(); +req.headers = new Headers(); +req.headers.set("Cookie", "full=of; tasty=chocolate"); + +const c = getCookies(request); +// c = { full: "of", tasty: "chocolate" } +``` + +To set a `Cookie` you can add `CookieOptions` to properly set your `Cookie` + +```ts +import { setCookie } from "https://deno.land/std/http/cookie.ts"; + +let res: Response = {}; +res.headers = new Headers(); +setCookie(res, { name: "Space", value: "Cat" }); +``` + +Deleting a `Cookie` will set its expiration date before now. +Forcing the browser to delete it. + +```ts +import { delCookie } from "https://deno.land/std/http/cookie.ts"; + +let res = new Response(); +delCookie(res, "deno"); +// Will append this header in the response +// "Set-Cookie: deno=; Expires=Thus, 01 Jan 1970 00:00:00 GMT" +``` + +**Note**: At the moment multiple `Set-Cookie` in a `Response` is not handled. + ## Example ```typescript diff --git a/http/cookie.ts b/http/cookie.ts index e78d4823899fb5..b7019ae20dccaf 100644 --- a/http/cookie.ts +++ b/http/cookie.ts @@ -1,15 +1,81 @@ // Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. -import { ServerRequest } from "./server.ts"; +// Structured similarly to Go's cookie.go +// https://github.com/golang/go/blob/master/src/net/http/cookie.go +import { ServerRequest, Response } from "./server.ts"; +import { assert } from "../testing/asserts.ts"; +import { toIMF } from "../datetime/mod.ts"; -export interface Cookie { +export interface Cookies { [key: string]: string; } -/* Parse the cookie of the Server Request */ -export function getCookie(rq: ServerRequest): Cookie { - if (rq.headers.has("Cookie")) { - const out: Cookie = {}; - const c = rq.headers.get("Cookie").split(";"); +export interface Cookie { + name: string; + value: string; + expires?: Date; + maxAge?: number; + domain?: string; + path?: string; + secure?: boolean; + httpOnly?: boolean; + sameSite?: SameSite; + unparsed?: string[]; +} + +export type SameSite = "Strict" | "Lax"; + +function toString(cookie: Cookie): string { + const out: string[] = []; + out.push(`${cookie.name}=${cookie.value}`); + + // Fallback for invalid Set-Cookie + // ref: https://tools.ietf.org/html/draft-ietf-httpbis-cookie-prefixes-00#section-3.1 + if (cookie.name.startsWith("__Secure")) { + cookie.secure = true; + } + if (cookie.name.startsWith("__Host")) { + cookie.path = "/"; + cookie.secure = true; + delete cookie.domain; + } + + if (cookie.secure) { + out.push("Secure"); + } + if (cookie.httpOnly) { + out.push("HttpOnly"); + } + if (Number.isInteger(cookie.maxAge)) { + assert(cookie.maxAge > 0, "Max-Age must be an integer superior to 0"); + out.push(`Max-Age=${cookie.maxAge}`); + } + if (cookie.domain) { + out.push(`Domain=${cookie.domain}`); + } + if (cookie.sameSite) { + out.push(`SameSite=${cookie.sameSite}`); + } + if (cookie.path) { + out.push(`Path=${cookie.path}`); + } + if (cookie.expires) { + let dateString = toIMF(cookie.expires); + out.push(`Expires=${dateString}`); + } + if (cookie.unparsed) { + out.push(cookie.unparsed.join("; ")); + } + return out.join("; "); +} + +/** + * Parse the cookies of the Server Request + * @param req Server Request + */ +export function getCookies(req: ServerRequest): Cookies { + if (req.headers.has("Cookie")) { + const out: Cookies = {}; + const c = req.headers.get("Cookie").split(";"); for (const kv of c) { const cookieVal = kv.split("="); const key = cookieVal.shift().trim(); @@ -19,3 +85,48 @@ export function getCookie(rq: ServerRequest): Cookie { } return {}; } + +/** + * Set the cookie header properly in the Response + * @param res Server Response + * @param cookie Cookie to set + * @param [cookie.name] Name of the cookie + * @param [cookie.value] Value of the cookie + * @param [cookie.expires] Expiration Date of the cookie + * @param [cookie.maxAge] Max-Age of the Cookie. Must be integer superior to 0 + * @param [cookie.domain] Specifies those hosts to which the cookie will be sent + * @param [cookie.path] Indicates a URL path that must exist in the request. + * @param [cookie.secure] Indicates if the cookie is made using SSL & HTTPS. + * @param [cookie.httpOnly] Indicates that cookie is not accessible via Javascript + * @param [cookie.sameSite] Allows servers to assert that a cookie ought not to be + * sent along with cross-site requests + * Example: + * + * setCookie(response, { name: 'deno', value: 'runtime', + * httpOnly: true, secure: true, maxAge: 2, domain: "deno.land" }); + */ +export function setCookie(res: Response, cookie: Cookie): void { + if (!res.headers) { + res.headers = new Headers(); + } + // TODO (zekth) : Add proper parsing of Set-Cookie headers + // Parsing cookie headers to make consistent set-cookie header + // ref: https://tools.ietf.org/html/rfc6265#section-4.1.1 + res.headers.set("Set-Cookie", toString(cookie)); +} + +/** + * Set the cookie header properly in the Response to delete it + * @param res Server Response + * @param name Name of the cookie to Delete + * Example: + * + * delCookie(res,'foo'); + */ +export function delCookie(res: Response, name: string): void { + setCookie(res, { + name: name, + value: "", + expires: new Date(0) + }); +} diff --git a/http/cookie_test.ts b/http/cookie_test.ts index e8f920b31c7367..ae99b2707b8e80 100644 --- a/http/cookie_test.ts +++ b/http/cookie_test.ts @@ -1,7 +1,7 @@ // Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. -import { ServerRequest } from "./server.ts"; -import { getCookie } from "./cookie.ts"; -import { assertEquals } from "../testing/asserts.ts"; +import { ServerRequest, Response } from "./server.ts"; +import { getCookies, delCookie, setCookie } from "./cookie.ts"; +import { assert, assertEquals } from "../testing/asserts.ts"; import { test } from "../testing/mod.ts"; test({ @@ -9,17 +9,191 @@ test({ fn(): void { let req = new ServerRequest(); req.headers = new Headers(); - assertEquals(getCookie(req), {}); + assertEquals(getCookies(req), {}); req.headers = new Headers(); req.headers.set("Cookie", "foo=bar"); - assertEquals(getCookie(req), { foo: "bar" }); + assertEquals(getCookies(req), { foo: "bar" }); req.headers = new Headers(); req.headers.set("Cookie", "full=of ; tasty=chocolate"); - assertEquals(getCookie(req), { full: "of ", tasty: "chocolate" }); + assertEquals(getCookies(req), { full: "of ", tasty: "chocolate" }); req.headers = new Headers(); req.headers.set("Cookie", "igot=99; problems=but..."); - assertEquals(getCookie(req), { igot: "99", problems: "but..." }); + assertEquals(getCookies(req), { igot: "99", problems: "but..." }); + } +}); + +test({ + name: "[HTTP] Cookie Delete", + fn(): void { + let res: Response = {}; + delCookie(res, "deno"); + assertEquals( + res.headers.get("Set-Cookie"), + "deno=; Expires=Thus, 01 Jan 1970 00:00:00 GMT" + ); + } +}); + +test({ + name: "[HTTP] Cookie Set", + fn(): void { + let res: Response = {}; + + res.headers = new Headers(); + setCookie(res, { name: "Space", value: "Cat" }); + assertEquals(res.headers.get("Set-Cookie"), "Space=Cat"); + + res.headers = new Headers(); + setCookie(res, { name: "Space", value: "Cat", secure: true }); + assertEquals(res.headers.get("Set-Cookie"), "Space=Cat; Secure"); + + res.headers = new Headers(); + setCookie(res, { name: "Space", value: "Cat", httpOnly: true }); + assertEquals(res.headers.get("Set-Cookie"), "Space=Cat; HttpOnly"); + + res.headers = new Headers(); + setCookie(res, { + name: "Space", + value: "Cat", + httpOnly: true, + secure: true + }); + assertEquals(res.headers.get("Set-Cookie"), "Space=Cat; Secure; HttpOnly"); + + res.headers = new Headers(); + setCookie(res, { + name: "Space", + value: "Cat", + httpOnly: true, + secure: true, + maxAge: 2 + }); + assertEquals( + res.headers.get("Set-Cookie"), + "Space=Cat; Secure; HttpOnly; Max-Age=2" + ); + + let error = false; + res.headers = new Headers(); + try { + setCookie(res, { + name: "Space", + value: "Cat", + httpOnly: true, + secure: true, + maxAge: 0 + }); + } catch (e) { + error = true; + } + assert(error); + + res.headers = new Headers(); + setCookie(res, { + name: "Space", + value: "Cat", + httpOnly: true, + secure: true, + maxAge: 2, + domain: "deno.land" + }); + assertEquals( + res.headers.get("Set-Cookie"), + "Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land" + ); + + res.headers = new Headers(); + setCookie(res, { + name: "Space", + value: "Cat", + httpOnly: true, + secure: true, + maxAge: 2, + domain: "deno.land", + sameSite: "Strict" + }); + assertEquals( + res.headers.get("Set-Cookie"), + "Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; SameSite=Strict" + ); + + res.headers = new Headers(); + setCookie(res, { + name: "Space", + value: "Cat", + httpOnly: true, + secure: true, + maxAge: 2, + domain: "deno.land", + sameSite: "Lax" + }); + assertEquals( + res.headers.get("Set-Cookie"), + "Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; SameSite=Lax" + ); + + res.headers = new Headers(); + setCookie(res, { + name: "Space", + value: "Cat", + httpOnly: true, + secure: true, + maxAge: 2, + domain: "deno.land", + path: "/" + }); + assertEquals( + res.headers.get("Set-Cookie"), + "Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/" + ); + + res.headers = new Headers(); + setCookie(res, { + name: "Space", + value: "Cat", + httpOnly: true, + secure: true, + maxAge: 2, + domain: "deno.land", + path: "/", + unparsed: ["unparsed=keyvalue", "batman=Bruce"] + }); + assertEquals( + res.headers.get("Set-Cookie"), + "Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; unparsed=keyvalue; batman=Bruce" + ); + + res.headers = new Headers(); + setCookie(res, { + name: "Space", + value: "Cat", + httpOnly: true, + secure: true, + maxAge: 2, + domain: "deno.land", + path: "/", + expires: new Date(Date.UTC(1983, 0, 7, 15, 32)) + }); + assertEquals( + res.headers.get("Set-Cookie"), + "Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; Expires=Fri, 07 Jan 1983 15:32:00 GMT" + ); + + res.headers = new Headers(); + setCookie(res, { name: "__Secure-Kitty", value: "Meow" }); + assertEquals(res.headers.get("Set-Cookie"), "__Secure-Kitty=Meow; Secure"); + + res.headers = new Headers(); + setCookie(res, { + name: "__Host-Kitty", + value: "Meow", + domain: "deno.land" + }); + assertEquals( + res.headers.get("Set-Cookie"), + "__Host-Kitty=Meow; Secure; Path=/" + ); } });