diff --git a/cheetah.ts b/cheetah.ts index 51e3513..394c0c6 100644 --- a/cheetah.ts +++ b/cheetah.ts @@ -65,7 +65,7 @@ export type AppConfig = { oauth?: { store: OAuthStore - cookie?: Parameters[2] + cookie?: Parameters[2] onSignIn?: ( c: Context, data: OAuthSessionData, @@ -82,6 +82,16 @@ export type AppConfig = { * @since v1.3 */ debug?: boolean + + /** + * Desc + * + * @since v1.4 + */ + versioning?: { + highest: 'v4' + lowest: `v${string}` + } } export class cheetah extends base() { diff --git a/jwt.ts b/jwt.ts new file mode 100644 index 0000000..1bebdb8 --- /dev/null +++ b/jwt.ts @@ -0,0 +1,92 @@ +// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. +import { + decode, + encode, +} from 'https://deno.land/std@0.198.0/encoding/base64.ts' +import { + create, + getNumericDate, + Payload as JwtPayload, + verify as _verify, + VerifyOptions, +} from 'https://deno.land/x/djwt@v2.8/mod.ts' + +interface Payload { + iss?: string + sub?: string + aud?: string[] | string + /** + * A `Date` object or a `number` (in seconds) when the JWT will expire. + */ + exp?: Date | number + /** + * A `Date` object or a `number` (in seconds) until which the JWT will be invalid. + */ + nbf?: Date | number + iat?: number + jti?: string + [key: string]: unknown +} + +export async function createKey() { + const key = await crypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-512' }, + true, + ['sign', 'verify'], + ) + + const exportedKey = await crypto.subtle.exportKey('raw', key) + + return encode(exportedKey) +} + +export function importKey(key: string) { + return crypto.subtle.importKey( + 'raw', + decode(key).buffer, + { name: 'HMAC', hash: 'SHA-512' }, + true, + ['sign', 'verify'], + ) +} + +/** + * Sign a payload. + */ +// deno-lint-ignore ban-types +export async function sign = {}>( + payload: T & Payload, + secret: string | CryptoKey, +) { + const key = typeof secret === 'string' ? await importKey(secret) : secret + + const { exp, nbf, ...rest } = payload + + return await create({ alg: 'HS512', typ: 'JWT' }, { + ...(exp && { exp: getNumericDate(exp) }), + ...(nbf && { nbf: getNumericDate(nbf) }), + ...rest, + }, key) +} + +/** + * Verify the validity of a JWT. + */ +export async function verify = Payload>( + token: string, + secret: string | CryptoKey, + options?: VerifyOptions, +) { + try { + const key = typeof secret === 'string' ? await importKey(secret) : secret + + return await _verify(token, key, options) as JwtPayload & T + } catch (_err) { + return + } +} + +export default { + sign, + verify, +} diff --git a/location_data.ts b/location_data.ts new file mode 100644 index 0000000..7f09aea --- /dev/null +++ b/location_data.ts @@ -0,0 +1,146 @@ +// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. +import { IncomingRequestCfProperties } from 'https://cdn.jsdelivr.net/npm/@cloudflare/workers-types@4.20230814.0/index.ts' +import { Context } from './context.ts' + +type CloudflareRequest = Request & { + cf: IncomingRequestCfProperties +} + +/** + * Inspect the geolocation data of the incoming request. + * + * You must either deploy your app to [Cloudflare Workers](https://developers.cloudflare.com/workers/runtime-apis/request/#incomingrequestcfproperties) or use [Cloudflare as a proxy](https://developers.cloudflare.com/support/network/configuring-ip-geolocation/) to use the `LocationData` API. + */ +export class LocationData { + #c: Context + + constructor(c: Context) { + this.#c = c + } + + /** + * The city the request originated from. + * + * @example 'Austin' + */ + get city() { + const city = (this.#c.req.raw as CloudflareRequest).cf?.city + + if (!city && this.#c.__app.proxy === 'cloudflare') { + return this.#c.req.headers['cf-ipcity'] + } + + return city + } + + /** + * If known, the ISO 3166-2 name for the first level region associated with the IP address of the incoming request. + * + * @example 'Texas' + */ + get region() { + return (this.#c.req.raw as CloudflareRequest).cf?.region + } + + /** + * The [ISO 3166-1 Alpha 2](https://www.iso.org/iso-3166-country-codes.html) country code the request originated from. + * + * If you're using CLoudflare Workers and your worker is [configured to accept TOR connections](https://support.cloudflare.com/hc/en-us/articles/203306930-Understanding-Cloudflare-Tor-support-and-Onion-Routing), this may also be `T1`, indicating a request that originated over TOR. + * + * If Cloudflare is unable to determine where the request originated this property is omitted. + * + * @example 'GB' + */ + get country(): IncomingRequestCfProperties['country'] { + const country = (this.#c.req.raw as CloudflareRequest).cf?.country + + if (!country && this.#c.__app.proxy === 'cloudflare') { + return this.#c.req + .headers['cf-ipcountry'] as IncomingRequestCfProperties['country'] + } + + return country + } + + /** + * A two-letter code indicating the continent the request originated from. + * + * @example 'NA' + */ + get continent(): IncomingRequestCfProperties['continent'] { + const continent = (this.#c.req.raw as CloudflareRequest).cf?.continent + + if (!continent && this.#c.__app.proxy === 'cloudflare') { + return this.#c.req + .headers['cf-ipcontinent'] as IncomingRequestCfProperties['continent'] + } + + return continent + } + + /** + * If known, the ISO 3166-2 code for the first-level region associated with the IP address of the incoming request. + * + * @example 'TX' + */ + get regionCode(): IncomingRequestCfProperties['regionCode'] { + return (this.#c.req.raw as CloudflareRequest).cf?.regionCode + } + + /** + * Latitude of the incoming request. + * + * @example '30.27130' + */ + get latitude(): IncomingRequestCfProperties['latitude'] { + const latitude = (this.#c.req.raw as CloudflareRequest).cf?.latitude + + if (!latitude && this.#c.__app.proxy === 'cloudflare') { + return this.#c.req.headers['cf-iplatitude'] + } + + return latitude + } + + /** + * Longitude of the incoming request. + * + * @example '-97.74260' + */ + get longitude(): IncomingRequestCfProperties['longitude'] { + const longitude = (this.#c.req.raw as CloudflareRequest).cf?.longitude + + if (!longitude && this.#c.__app.proxy === 'cloudflare') { + return this.#c.req.headers['cf-iplongitude'] + } + + return longitude + } + + /** + * Postal code of the incoming request. + * + * @example '78701' + */ + get postalCode(): IncomingRequestCfProperties['postalCode'] { + return (this.#c.req.raw as CloudflareRequest).cf?.postalCode + } + + /** + * Timezone of the incoming request. + * + * @example 'America/Chicago' + */ + get timezone(): IncomingRequestCfProperties['timezone'] { + return (this.#c.req.raw as CloudflareRequest).cf?.timezone + } + + /** + * The three-letter [IATA](https://en.wikipedia.org/wiki/IATA_airport_code) airport code of the data center that the request hit. + * + * @example 'DFW' + */ + get datacenter(): IncomingRequestCfProperties['colo'] { + return (this.#c.req.raw as CloudflareRequest).cf?.colo + } +} diff --git a/mod.ts b/mod.ts index ba285c2..93c6db3 100644 --- a/mod.ts +++ b/mod.ts @@ -6,56 +6,66 @@ export { Context } from './context.ts' export { Exception } from './exception.ts' export { createExtension } from './extensions.ts' export type { Extension } from './extensions.ts' +export { default as jwt } from './jwt.ts' +export { LocationData } from './location_data.ts' +export { otp } from './otp.ts' +export { sendMail } from './send_mail.ts' +export { Store } from './store.ts' /* crypto ------------------------------------------------------------------- */ import { decode } from 'https://deno.land/std@0.198.0/encoding/base64.ts' +import { Context } from './context.ts' -export async function encrypt(key: string, message: string) { - const iv = crypto.getRandomValues(new Uint8Array(12)), - ivStr = Array.from(iv) - .map((byte) => String.fromCharCode(byte)) - .join(''), - alg = { name: 'AES-GCM', iv }, - cryptoKey = await crypto.subtle.importKey( - 'raw', - decode(key).buffer, - alg, - true, - ['encrypt', 'decrypt'], - ), - cipherBuf = await crypto.subtle.encrypt( - alg, - cryptoKey, - new TextEncoder().encode(message), - ), - cipherArr = Array.from(new Uint8Array(cipherBuf)), - cipherStr = cipherArr.map((byte) => String.fromCharCode(byte)) - .join('') +export async function encrypt(c: Context, message: string) { + const key = (c.env('crypto_key') ?? c.env('CRYPTO_KEY')) as string + + const iv = crypto.getRandomValues(new Uint8Array(12)) + const ivStr = Array.from(iv) + .map((byte) => String.fromCharCode(byte)) + .join('') + const alg = { name: 'AES-GCM', iv } + const cryptoKey = await crypto.subtle.importKey( + 'raw', + decode(key).buffer, + alg, + true, + ['encrypt', 'decrypt'], + ) + const cipherBuf = await crypto.subtle.encrypt( + alg, + cryptoKey, + new TextEncoder().encode(message), + ) + const cipherArr = Array.from(new Uint8Array(cipherBuf)) + const cipherStr = cipherArr.map((byte) => String.fromCharCode(byte)) + .join('') return btoa(ivStr + cipherStr) } -export async function decrypt(key: string, message: string) { - const iv = atob(message).slice(0, 12), - alg = { - name: 'AES-GCM', - iv: new Uint8Array( - Array.from(iv).map((char) => char.charCodeAt(0)), - ), - }, - cryptoKey = await crypto.subtle.importKey( - 'raw', - decode(key).buffer, - alg, - true, - ['encrypt', 'decrypt'], - ), - cipherStr = atob(message).slice(12), - cipherBuf = new Uint8Array( - Array.from(cipherStr).map((char) => char.charCodeAt(0)), +export async function decrypt(c: Context, message: string) { + const key = (c.env('crypto_key') ?? c.env('CRYPTO_KEY')) as string + + const iv = atob(message).slice(0, 12) + const alg = { + name: 'AES-GCM', + iv: new Uint8Array( + Array.from(iv).map((char) => char.charCodeAt(0)), ), - buf = await crypto.subtle.decrypt(alg, cryptoKey, cipherBuf) + } + const cryptoKey = await crypto.subtle.importKey( + 'raw', + decode(key).buffer, + alg, + true, + ['encrypt', 'decrypt'], + ) + const cipherStr = atob(message).slice(12) + const cipherBuf = new Uint8Array( + Array.from(cipherStr).map((char) => char.charCodeAt(0)), + ) + const buf = await crypto.subtle.decrypt(alg, cryptoKey, cipherBuf) return new TextDecoder().decode(buf) } diff --git a/otp.ts b/otp.ts new file mode 100644 index 0000000..9539863 --- /dev/null +++ b/otp.ts @@ -0,0 +1,57 @@ +// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. +import * as OTP from 'https://deno.land/x/otpauth@v9.1.4/dist/otpauth.esm.js' + +export const otp = { + /** + * Create a random secret. + */ + secret(length = 64) { + return [...Array(length)].map(() => + Math.floor(Math.random() * 16).toString(16) + ).join('') + }, + + /** + * Get the 6-digit token for a given timestamp. + */ + token(secret: string, timestamp?: number) { + const totp = new OTP.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: OTP.Secret.fromHex(secret), + }) + + return totp.generate({ timestamp }) + }, + + /** + * Create a URI that you can use, for example, for a QR code to scan with Google Authenticator. + */ + uri(label: string, issuer: string, secret: string) { + const totp = new OTP.TOTP({ + issuer, + label, + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: OTP.Secret.fromHex(secret), + }) + + return totp.toString() + }, + + /** + * Determine if a given token is valid. + */ + validate(token: string, secret: string) { + const totp = new OTP.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: OTP.Secret.fromHex(secret), + }) + + return totp.validate({ token }) === 0 + }, +} diff --git a/send_mail.ts b/send_mail.ts new file mode 100644 index 0000000..58c0a64 --- /dev/null +++ b/send_mail.ts @@ -0,0 +1,94 @@ +// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. +type MailContact = + | { name?: string; email: string } + | { name?: string; email: string }[] + | string + | string[] + +/** + * Send an email through [mailchannels](https://blog.cloudflare.com/sending-email-from-workers-with-mailchannels). + */ +export function sendMail( + options: { + subject: string + message: string + from: { + name: string + email: string + } + to: MailContact + cc?: MailContact + bcc?: MailContact + reply?: boolean + dkim?: { + domain?: string + privateKey?: string + selector?: string + } + }, +) { + // to + const to: { name?: string; email: string }[] = [] + + if (typeof options.to === 'string') { + to.push({ email: options.to }) + } else if (options.to instanceof Array) { + for (const recipient of options.to) { + to.push(typeof recipient === 'string' ? { email: recipient } : recipient) + } + } else { + to.push(options.to) + } + + // cc + const cc: { name?: string; email: string }[] = [] + + if (typeof options.cc === 'string') { + cc.push({ email: options.cc }) + } else if (options.cc instanceof Array) { + for (const recipient of options.cc) { + cc.push(typeof recipient === 'string' ? { email: recipient } : recipient) + } + } else if (options.cc) { + cc.push(options.cc) + } + + // bcc + const bcc: { name?: string; email: string }[] = [] + + if (typeof options.bcc === 'string') { + bcc.push({ email: options.bcc }) + } else if (options.bcc instanceof Array) { + for (const recipient of options.bcc) { + bcc.push( + typeof recipient === 'string' ? { email: recipient } : recipient, + ) + } + } else if (options.bcc) { + bcc.push(options.bcc) + } + + return fetch('https://api.mailchannels.net/tx/v1/send', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + personalizations: [{ + to, + ...(cc.length > 0 && { cc }), + ...(bcc.length > 0 && { bcc }), + ...(options.dkim?.domain && { dkim_domain: options.to }), + ...(options.dkim?.privateKey && { dkim_private_key: options.to }), + ...(options.dkim?.selector && { dkim_selector: options.to }), + }], + from: options.from, + subject: options.subject, + content: [{ + type: options.message.startsWith('') ? 'text/html' : 'text/plain', + value: options.message, + }], + ...(options.reply && { reply_to: to[0] }), + }), + }) +} diff --git a/store.ts b/store.ts new file mode 100644 index 0000000..c7ea8c7 --- /dev/null +++ b/store.ts @@ -0,0 +1,115 @@ +// Copyright 2023 Samuel Kopp. All rights reserved. Apache-2.0 license. +import { encode } from 'https://deno.land/std@0.198.0/encoding/base64.ts' +import { Context } from './context.ts' + +export class Store { + #cache: globalThis.Cache | null + #context + #maxAge + #name + + constructor(c: Context, { + maxAge = 600, + name = 'cheetah', + }: { + /** + * Duration in seconds for how long a response should be cached. + * + * @default 600 + */ + maxAge?: number + /** A unique name for your cache. */ + name?: string + } = {}) { + this.#cache = null + this.#context = c + this.#maxAge = maxAge + this.#name = name + } + + async set(key: string, data: string | Record | Uint8Array) { + if (this.#cache === null) { + this.#cache = await caches.open(this.#name) + } + + this.#context.waitUntil( + this.#cache.put( + `https://${this.#name}.com/${encode(key)}`, + new Response( + typeof data === 'string' || data instanceof Uint8Array + ? data + : JSON.stringify(data), + { + headers: { + 'cache-control': `max-age=${this.#maxAge}`, + }, + }, + ), + ), + ) + } + + get = Record>( + key: string, + type: 'json', + ): Promise + get( + key: string, + type: 'string', + ): Promise + get( + key: string, + type: 'buffer', + ): Promise + + async get | string | Uint8Array>( + key: string, + type: 'string' | 'json' | 'buffer' = 'string', + ): Promise { + if (this.#cache === null) { + this.#cache = await caches.open(this.#name) + } + + try { + const result = await this.#cache.match( + `https://${this.#name}.com/${encode(key)}`, + ) + + if (!result) { + return undefined + } + + const data = type === 'string' + ? await result.text() + : type === 'json' + ? await result.json() + : new Uint8Array(await result.arrayBuffer()) + + return data + } catch (_err) { + return undefined + } + } + + async has(key: string) { + if (this.#cache === null) { + this.#cache = await caches.open(this.#name) + } + + const result = await this.#cache.match( + `https://${this.#name}.com/${encode(key)}`, + ) + + return result !== undefined + } + + async delete(key: string) { + if (this.#cache === null) { + this.#cache = await caches.open(this.#name) + } + + return await this.#cache.delete( + `https://${this.#name}.com/${encode(key)}`, + ) + } +}