Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"devDependencies": {
"@commitlint/cli": "latest",
"@commitlint/config-conventional": "latest",
"ava": "latest",
"ava": "7",
"c8": "latest",
"ci-publish": "latest",
"finepack": "latest",
Expand Down
183 changes: 111 additions & 72 deletions src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,150 +6,189 @@ require('update-notifier')({ pkg: require('../package.json') }).notify()

const getContentType = require('@kikobeats/content-type')
const { URLSearchParams } = require('url')
const clipboardy = require('clipboardy')
const mql = require('@microlink/mql')
const prettyMs = require('pretty-ms')
const temp = require('temperment')
const fs = require('fs')
const os = require('os')

const { toClipboard, toPlainHeaders } = require('./util')
const { gray, green } = require('./colors')
const print = require('./print')
const printJson = require('./print-json')
const printText = require('./print-text')
const exit = require('./exit')

const microlinkUrl = () =>
/^https?:\/\/((?!fonts|geolocation\.)[a-z0-9-]+\.)+microlink\.io/

const normalizeInput = input => {
if (!input) return input
;[
let normalized = input
const sanitizers = [
microlinkUrl,
() => require('is-local-address/ipv4').regex,
() => require('is-local-address/ipv6').regex
].forEach(regex => {
return (input = input.replace(regex(), ''))
})
return input.replace(/^\??url=/, '')
]

for (const createRegex of sanitizers) {
normalized = normalized.replace(createRegex(), '')
}

return normalized.replace(/^\??url=/, '')
}

const getInput = input => {
const collection = input.length === 1 ? input[0].split(os.EOL) : input
return collection.reduce((acc, item) => acc + item.trim(), '')
return collection.map(item => item.trim()).join('')
}

const toPlainObject = input => Object.fromEntries(new URLSearchParams(input))

const fetch = async (cli, gotOpts) => {
const { pretty, color, copy, endpoint, ...flags } = cli.flags
const {
pretty,
copy,
json,
'json-full': jsonFull,
endpoint,
...flags
} = cli.flags
const isJson = json || jsonFull
const input = getInput(cli.input, endpoint)
const { url, ...queryParams } = toPlainObject(`url=${normalizeInput(input)}`)
const mqlOpts = { endpoint, ...queryParams, ...flags }
const spinner = print.spinner()
const spinner = printText.spinner()
const shouldSpin = !isJson && pretty

let mergedGotOpts = gotOpts
if (Object.keys(cli.headers).length > 0) {
mergedGotOpts = {
...gotOpts,
headers: { ...gotOpts.headers, ...cli.headers }
}
}

const mergedGotOpts = Object.keys(cli.headers).length > 0
? { ...gotOpts, headers: { ...gotOpts.headers, ...cli.headers } }
: gotOpts
const [requestUrl, requestOptions] = mql.getApiUrl(
url,
mqlOpts,
mergedGotOpts
)

try {
spinner.start()
const response = await mql.buffer(url, mqlOpts, mergedGotOpts)
spinner.stop()
return { response, flags: { copy, pretty } }
if (shouldSpin) spinner.start()

const start = Date.now()
const request = isJson ? mql : mql.buffer
const mqlResponse = await request(url, mqlOpts, mergedGotOpts)
const duration = Date.now() - start

let response = mqlResponse
if (isJson) {
response = printJson({
requestUrl,
requestOptions,
response: mqlResponse.response,
full: jsonFull,
pretty
})
}

return { response, duration, flags: { copy, pretty, json: isJson } }
} catch (error) {
spinner.stop()
error.flags = cli.flags
throw error
} finally {
if (shouldSpin) spinner.stop()
}
}

const render = ({ response, flags }) => {
const { headers, timings, requestUrl: uri, body } = response
if (!flags.pretty) return console.log(body.toString())

const contentType = getContentType(headers['content-type'])
const time = prettyMs(timings.phases.total)
const serverTiming = headers['server-timing']
const id = headers['x-request-id']

const printMode = (() => {
if (body.toString().startsWith('data:')) return 'base64'
if (contentType !== 'application/json') return 'image'
})()

switch (printMode) {
case 'base64': {
const extension = contentType.split('/')[1].split(';')[0]
const filepath = temp.file({ extension })
fs.writeFileSync(filepath, body.toString().split(',')[1], 'base64')
print.image(filepath)
break
}
case 'image':
print.image(body)
console.log()
break
default: {
const isText = contentType === 'text/plain'
const isHtml = contentType === 'text/html'
const output = isText || isHtml ? body.toString() : JSON.parse(body)
print.json(output, flags)
break
}
const render = ({ response, duration, flags }) => {
const { headers, requestUrl, url: responseUrl, body } = response

if (flags.json) {
if (flags.copy) toClipboard(JSON.parse(response), flags)
if (!flags.pretty) return console.log(response)

return printText.json(JSON.parse(response), { color: true })
}

const edgeCacheStatus = headers['cf-cache-status']
const unifiedCacheStatus = headers['x-cache-status']
const plainHeaders = toPlainHeaders(headers)

const bodyBuffer = Buffer.isBuffer(body) ? body : Buffer.from(body)
const bodyText = bodyBuffer.toString()

if (!flags.pretty) return console.log(bodyText)

const contentType = getContentType(plainHeaders['content-type'])
const time = Number.isFinite(duration) ? prettyMs(duration) : 'unknown'
const serverTiming = plainHeaders['server-timing']
const id = plainHeaders['x-request-id']

if (bodyText.startsWith('data:')) {
const extension = contentType
? contentType.split('/')[1].split(';')[0]
: 'png'
const filepath = temp.file({ extension })
fs.writeFileSync(filepath, bodyText.split(',')[1], 'base64')
printText.image(filepath)
} else if (contentType !== 'application/json') {
printText.image(bodyBuffer)
console.log()
} else {
const isText = contentType === 'text/plain'
const isHtml = contentType === 'text/html'
const output = isText || isHtml ? bodyText : JSON.parse(bodyText)
printText.json(output, flags)
}

const edgeCacheStatus = plainHeaders['cf-cache-status']
const unifiedCacheStatus = plainHeaders['x-cache-status']

const cacheStatus =
unifiedCacheStatus === 'MISS' && edgeCacheStatus === 'HIT'
? edgeCacheStatus
: unifiedCacheStatus

const timestamp = Number(headers['x-timestamp'])
const ttl = Number(headers['x-cache-ttl'])
const timestamp = Number(plainHeaders['x-timestamp'])
const ttl = Number(plainHeaders['x-cache-ttl'])
const expires = timestamp + ttl - Date.now()
const expiration = prettyMs(expires)
const expiredAt = cacheStatus === 'HIT' ? `(${expiration})` : ''
const fetchMode = headers['x-fetch-mode']
const fetchTime = fetchMode && `(${headers['x-fetch-time']})`
const size = Number(headers['content-length'] || Buffer.byteLength(body))
const fetchMode = plainHeaders['x-fetch-mode']
const fetchTime = fetchMode && `(${plainHeaders['x-fetch-time']})`
const size = Number(plainHeaders['content-length'] || bodyBuffer.length)
const uri = requestUrl || responseUrl

console.error()
console.error(
print.label('success', 'green'),
gray(`${print.bytes(size)} in ${time}`)
printText.label('success', 'green'),
gray(`${printText.bytes(size)} in ${time}`)
)
console.error()

if (serverTiming) {
console.error(' ', print.keyValue(green('timing'), serverTiming))
console.error(' ', printText.keyValue(green('timing'), serverTiming))
}

if (cacheStatus) {
console.error(
' ',
print.keyValue(green('cache'), `${cacheStatus} ${gray(expiredAt)}`)
printText.keyValue(green('cache'), `${cacheStatus} ${gray(expiredAt)}`)
)
}

if (fetchMode) {
console.error(
' ',
print.keyValue(green('mode'), `${fetchMode} ${gray(fetchTime)}`)
printText.keyValue(green('mode'), `${fetchMode} ${gray(fetchTime)}`)
)
}

console.error(' ', print.keyValue(green('uri'), uri))
console.error(' ', print.keyValue(green('id'), id))
console.error(' ', printText.keyValue(green('uri'), uri))
console.error(' ', printText.keyValue(green('id'), id))

if (flags.copy) {
let copiedValue
try {
copiedValue = JSON.parse(body)
} catch (err) {
copiedValue = body
}
clipboardy.writeSync(JSON.stringify(copiedValue, null, 2))
toClipboard(JSON.parse(bodyText), flags)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy with non-JSON body crashes without try-catch

Medium Severity

toClipboard(JSON.parse(bodyText), flags) will throw when bodyText isn't valid JSON (e.g., data URIs, image buffers, or text/plain responses) and --copy is used. The previous implementation wrapped JSON.parse(body) in a try-catch, falling back to the raw body on parse failure. That safety net was removed in this rewrite.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a2ac8f5. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing try-catch crashes copy for non-JSON responses

High Severity

JSON.parse(bodyText) in the flags.copy path will throw a SyntaxError when the response body is not valid JSON. This applies to base64 data URIs, images, text/plain, and text/html responses — all of which reach this line in the pretty-mode flow. The old code had a try-catch that gracefully fell back to the raw body when parsing failed; that guard was removed in this refactor.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3e95b41. Configure here.

console.error(`\n ${gray('Copied to clipboard!')}`)
}
}
Expand Down
24 changes: 8 additions & 16 deletions src/cli.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,22 @@
'use strict'

const mri = require('mri')
const { hasColorizedOutput, parseHeaders } = require('./util')

const { _, header, ...flags } = mri(process.argv.slice(2), {
const parsed = mri(process.argv.slice(2), {
alias: { H: 'header' },
boolean: ['color', 'copy', 'pretty'],
boolean: ['copy', 'json', 'json-full', 'pretty'],
string: ['header'],
default: {
apiKey: process.env.MICROLINK_API_KEY,
pretty: true,
color: true,
copy: false
pretty: hasColorizedOutput(),
copy: false,
json: false,
'json-full': false
}
})

const parseHeaders = raw => {
if (!raw) return {}
const entries = Array.isArray(raw) ? raw : [raw]
const headers = {}
for (const entry of entries) {
const idx = entry.indexOf(':')
if (idx === -1) continue
headers[entry.slice(0, idx).trim().toLowerCase()] = entry.slice(idx + 1).trim()
}
return headers
}
const { _, header, ...flags } = parsed

const headers = parseHeaders(header)

Expand Down
2 changes: 1 addition & 1 deletion src/exit.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

const { gray, red } = require('./colors')

const print = require('./print')
const print = require('./print-text')

module.exports = async (promise, { flags }) => {
try {
Expand Down
13 changes: 12 additions & 1 deletion src/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,21 @@ Flags
white('$MICROLINK_API_KEY')
)}`
)}
${gray(`--colors colorize output (default is ${code(white('true'))}`)}
${gray(
`--copy copy output to clipboard (default is ${code(
white('false')
)}).`
)}
${gray(
`--json print request & response payload as JSON (API key masked, default is ${code(
white('false')
)}).`
)}
${gray(
`--json-full print request & response payload as JSON including full API key (default is ${code(
white('false')
)}).`
)}
${gray(
`--pretty beauty response payload (default is ${code(
white('true')
Expand All @@ -30,6 +39,8 @@ Flags
Examples
${gray('microlink https://microlink.io&palette')}
${gray('microlink https://microlink.io&palette --no-pretty')}
${gray('microlink https://microlink.io&palette --json')}
${gray('microlink https://microlink.io&palette --json-full')}
${gray('microlink https://microlink.io&palette --api-key=MyApiKey')}
${gray("microlink https://example.com -H 'x-user-cookie: 1'")}
`
3 changes: 1 addition & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@
module.exports = {
cli: require('./cli'),
api: require('./api'),
exit: require('./exit'),
print: require('./print')
exit: require('./exit')
}
33 changes: 33 additions & 0 deletions src/print-json.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use strict'

const { humanizeApiKey, toPlainHeaders, stringify } = require('./util')

module.exports = ({
requestUrl,
requestOptions,
response,
full = false,
pretty = true
}) => {
const { responseType, ...requestOptionsWithoutResponseType } = requestOptions
const headers = { ...requestOptionsWithoutResponseType.headers }

if (!full && headers['x-api-key']) {
headers['x-api-key'] = humanizeApiKey(headers['x-api-key'])
}

return stringify(
{
request: {
url: requestUrl,
...requestOptionsWithoutResponseType,
headers
},
response: {
...response,
headers: toPlainHeaders(response.headers)
}
},
{ pretty }
)
}
File renamed without changes.
Loading