Skip to content

Commit

Permalink
feat(http): support http.file and http.isLocal
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Feb 10, 2024
1 parent a0dc5f8 commit c2ff28a
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 12 deletions.
16 changes: 14 additions & 2 deletions packages/http/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,25 @@
"name": "@cordisjs/plugin-http",
"description": "Axios-style HTTP client service for Cordis",
"version": "0.1.0",
"main": "lib/index.mjs",
"type": "module",
"module": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"require": "./lib/index.cjs",
"import": "./lib/index.mjs",
"import": "./lib/index.js",
"types": "./lib/index.d.ts"
},
"./adapter": {
"node": {
"require": "./lib/adapter/node.cjs",
"import": "./lib/adapter/node.js"
},
"browser": {
"import": "./lib/adapter/browser.js"
},
"types": "./lib/adapter/index.d.ts"
},
"./package.json": "./package.json"
},
"files": [
Expand Down Expand Up @@ -43,6 +54,7 @@
},
"dependencies": {
"cosmokit": "^1.5.2",
"file-type": "^16.5.4",
"unws": "^0.2.4",
"ws": "^8.16.0"
}
Expand Down
33 changes: 33 additions & 0 deletions packages/http/src/adapter/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Modified from https://github.com/sindresorhus/ip-regex/blob/3e220cae3eb66ecfdf4f7678bea7306ceaa41c76/index.js

import { LookupAddress } from 'dns'
import { HTTP } from '../index.js'

const v4 = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/

const v6seg = '[a-fA-F\\d]{1,4}'

/* eslint-disable no-multi-spaces */
const v6core = [
`(?:${v6seg}:){7}(?:${v6seg}|:)`, // 1:2:3:4:5:6:7:: 1:2:3:4:5:6:7:8
`(?:${v6seg}:){6}(?:${v4}|:${v6seg}|:)`, // 1:2:3:4:5:6:: 1:2:3:4:5:6::8 1:2:3:4:5:6::8 1:2:3:4:5:6::1.2.3.4
`(?:${v6seg}:){5}(?::${v4}|(?::${v6seg}){1,2}|:)`, // 1:2:3:4:5:: 1:2:3:4:5::7:8 1:2:3:4:5::8 1:2:3:4:5::7:1.2.3.4
`(?:${v6seg}:){4}(?:(?::${v6seg}){0,1}:${v4}|(?::${v6seg}){1,3}|:)`, // 1:2:3:4:: 1:2:3:4::6:7:8 1:2:3:4::8 1:2:3:4::6:7:1.2.3.4
`(?:${v6seg}:){3}(?:(?::${v6seg}){0,2}:${v4}|(?::${v6seg}){1,4}|:)`, // 1:2:3:: 1:2:3::5:6:7:8 1:2:3::8 1:2:3::5:6:7:1.2.3.4
`(?:${v6seg}:){2}(?:(?::${v6seg}){0,3}:${v4}|(?::${v6seg}){1,5}|:)`, // 1:2:: 1:2::4:5:6:7:8 1:2::8 1:2::4:5:6:7:1.2.3.4
`(?:${v6seg}:){1}(?:(?::${v6seg}){0,4}:${v4}|(?::${v6seg}){1,6}|:)`, // 1:: 1::3:4:5:6:7:8 1::8 1::3:4:5:6:7:1.2.3.4
`(?::(?:(?::${v6seg}){0,5}:${v4}|(?::${v6seg}){1,7}|:))`, // ::2:3:4:5:6:7:8 ::2:3:4:5:6:7:8 ::8 ::1.2.3.4
]
/* eslint-enable no-multi-spaces */

const v6 = new RegExp(`^(?:${v6core.join('|')})(?:%[0-9a-zA-Z]{1,})?$`)

export async function lookup(address: string): Promise<LookupAddress> {
if (v4.test(address)) return { address, family: 4 }
if (v6.test(address)) return { address, family: 6 }
throw new Error('Invalid IP address')
}

export async function loadFile(url: string): Promise<HTTP.FileResponse | undefined> {
return undefined
}
5 changes: 5 additions & 0 deletions packages/http/src/adapter/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { LookupAddress } from 'dns'
import { HTTP } from '../index.ts'

export function loadFile(url: string): Promise<HTTP.FileResponse | undefined>
export function lookup(address: string): Promise<LookupAddress>
15 changes: 15 additions & 0 deletions packages/http/src/adapter/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { fileURLToPath } from 'node:url'
import { basename } from 'node:path'
import { fromBuffer } from 'file-type'
import { HTTP } from '../index.js'
import { readFile } from 'node:fs/promises'

export { lookup } from 'node:dns/promises'

export async function loadFile(url: string): Promise<HTTP.FileResponse | undefined> {
if (url.startsWith('file://')) {
const data = await readFile(fileURLToPath(url))
const result = await fromBuffer(data)
return { mime: result?.mime, name: basename(url), data }
}
}
65 changes: 57 additions & 8 deletions packages/http/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Context } from 'cordis'
import { Dict, trimSlash } from 'cosmokit'
import { base64ToArrayBuffer, Dict, trimSlash } from 'cosmokit'
import { WebSocket } from 'unws'
import { ClientOptions } from 'ws'
import { loadFile, lookup } from './adapter/index.js'
import { isLocalAddress } from './utils.js'

declare module 'cordis' {
interface Context {
Expand All @@ -19,15 +21,19 @@ export interface HTTP {
[Context.current]: Context
<T>(url: string | URL, config?: HTTP.RequestConfig): Promise<HTTP.Response<T>>
<T>(method: HTTP.Method, url: string | URL, config?: HTTP.RequestConfig): Promise<HTTP.Response<T>>
/** @deprecated use `ctx.http()` instead */
axios<T>(url: string, config?: HTTP.RequestConfig): Promise<HTTP.Response<T>>

get: HTTP.Request1
delete: HTTP.Request1
patch: HTTP.Request2
post: HTTP.Request2
put: HTTP.Request2
head(url: string, config?: HTTP.RequestConfig): Promise<Dict>
/** @deprecated use `ctx.http()` instead */
axios<T>(url: string, config?: HTTP.RequestConfig): Promise<HTTP.Response<T>>
ws(url: string, config?: HTTP.RequestConfig): Promise<WebSocket>

isLocal(url: string): Promise<boolean>
file(url: string, config?: HTTP.FileConfig): Promise<HTTP.FileResponse>
}

export namespace HTTP {
Expand Down Expand Up @@ -89,7 +95,17 @@ export namespace HTTP {
data: T
status: number
statusText: string
headers: Dict
headers: Headers
}

export interface FileConfig {
timeout?: number | string
}

export interface FileResponse {
mime?: string
name?: string
data: ArrayBufferLike
}
}

Expand Down Expand Up @@ -171,7 +187,7 @@ export function apply(ctx: Context, config?: HTTP.Config) {
url: raw.url,
status: raw.status,
statusText: raw.statusText,
headers: Object.fromEntries(raw.headers),
headers: raw.headers,
}

if (!raw.ok) {
Expand All @@ -193,6 +209,12 @@ export function apply(ctx: Context, config?: HTTP.Config) {
return response
} as HTTP

http.axios = async function (this: HTTP, url: string, config?: HTTP.Config) {
const caller = this[Context.current]
caller.emit('internal/warning', 'ctx.http.axios() is deprecated, use ctx.http() instead')
return caller.http(url, config)
}

for (const method of ['get', 'delete'] as const) {
http[method] = async function <T>(this: HTTP, url: string, config?: HTTP.Config) {
const caller = this[Context.current]
Expand Down Expand Up @@ -239,10 +261,37 @@ export function apply(ctx: Context, config?: HTTP.Config) {
return socket
}

http.axios = async function (this: HTTP, url: string, config?: HTTP.Config) {
http.file = async function file(this: HTTP, url: string, options: HTTP.FileConfig = {}): Promise<HTTP.FileResponse> {
const result = await loadFile(url)
if (result) return result
const caller = this[Context.current]
caller.emit('internal/warning', 'ctx.http.axios() is deprecated, use ctx.http() instead')
return caller.http(url, config)
const capture = /^data:([\w/-]+);base64,(.*)$/.exec(url)
if (capture) {
const [, mime, base64] = capture
return { mime, data: base64ToArrayBuffer(base64) }
}
const { headers, data, url: responseUrl } = await caller.http<ArrayBuffer>(url, {
method: 'GET',
responseType: 'arraybuffer',
timeout: +options.timeout! || undefined,
})
const mime = headers['content-type']
const [, name] = responseUrl.match(/.+\/([^/?]*)(?=\?)?/)!
return { mime, name, data }
}

http.isLocal = async function isLocal(url: string) {
let { hostname, protocol } = new URL(url)
if (protocol !== 'http:' && protocol !== 'https:') return true
if (/^\[.+\]$/.test(hostname)) {
hostname = hostname.slice(1, -1)
}
try {
const address = await lookup(hostname)
return isLocalAddress(address)
} catch {
return false
}
}

ctx.http = Context.associate(http, 'http')
Expand Down
81 changes: 81 additions & 0 deletions packages/http/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { LookupAddress } from 'dns'

/* eslint-disable no-multi-spaces */
const bogonV4 = [
'0.0.0.0/8', // RFC 1122 'this' network
'10.0.0.0/8', // RFC 1918 private space
'100.64.0.0/10', // RFC 6598 Carrier grade nat space
'127.0.0.0/8', // RFC 1122 localhost
'169.254.0.0/16', // RFC 3927 link local
'172.16.0.0/12', // RFC 1918 private space
'192.0.2.0/24', // RFC 5737 TEST-NET-1
'192.88.99.0/24', // RFC 7526 6to4 anycast relay
'192.168.0.0/16', // RFC 1918 private space
'198.18.0.0/15', // RFC 2544 benchmarking
'198.51.100.0/24', // RFC 5737 TEST-NET-2
'203.0.113.0/24', // RFC 5737 TEST-NET-3
'224.0.0.0/4', // multicast
'240.0.0.0/4', // reserved
]

const bogonV6 = [
'::/8', // RFC 4291 IPv4-compatible, loopback, et al
'0100::/64', // RFC 6666 Discard-Only
'2001:2::/48', // RFC 5180 BMWG
'2001:10::/28', // RFC 4843 ORCHID
'2001:db8::/32', // RFC 3849 documentation
'2002::/16', // RFC 7526 6to4 anycast relay
'3ffe::/16', // RFC 3701 old 6bone
'fc00::/7', // RFC 4193 unique local unicast
'fe80::/10', // RFC 4291 link local unicast
'fec0::/10', // RFC 3879 old site local unicast
'ff00::/8', // RFC 4291 multicast
]
/* eslint-enable no-multi-spaces */

function parseIPv4(ip: string) {
return ip.split('.').reduce((a, b) => (a << 8n) + BigInt(b), 0n)
}

function parseIPv6(ip: string) {
const exp = ip.indexOf('::')
let num = 0n
// :: 左边有内容
if (exp !== -1 && exp !== 0) {
ip.slice(0, exp).split(':').forEach((piece, i) => {
num |= BigInt(`0x${piece}`) << BigInt((7 - i) * 16)
})
}
// :: 在最右边
if (exp === ip.length - 2) {
return num
}
// :: 右边的内容
const rest = exp === -1 ? ip : ip.slice(exp + 2)
const v4 = rest.includes('.')
const pieces = rest.split(':')
let start = 0
if (v4) {
start += 2
const [addr] = pieces.splice(-1, 1)
num |= parseIPv4(addr)
}
pieces.reverse().forEach((piece, i) => {
num |= BigInt(`0x${piece}`) << BigInt((start + i) * 8)
})
return num
}

export async function isLocalAddress({ address, family }: LookupAddress) {
if (family !== 4 && family !== 6) return false
const { bogons, length, parse } = family === 4
? { bogons: bogonV4, length: 32, parse: parseIPv4 }
: { bogons: bogonV6, length: 128, parse: parseIPv6 }
const num = parse(address)
for (const bogon of bogons) {
const [prefix, cidr] = bogon.split('/')
const mask = ((1n << BigInt(cidr)) - 1n) << BigInt(length - +cidr)
if ((num & mask) === parse(prefix)) return true
}
return false
}
3 changes: 1 addition & 2 deletions tsconfig.base.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "es2022",
"module": "commonjs",
"module": "nodenext",
"declaration": true,
"emitDeclarationOnly": true,
"strict": true,
Expand All @@ -10,6 +10,5 @@
"incremental": true,
"skipLibCheck": true,
"esModuleInterop": true,
"moduleResolution": "node",
},
}

0 comments on commit c2ff28a

Please sign in to comment.