@unavatar/core 3.45.26 → 3.46.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@unavatar/core",
3
3
  "description": "Get unified user avatar from social networks, including Instagram, SoundCloud, Telegram, Twitter, YouTube & more.",
4
4
  "homepage": "https://unavatar.io",
5
- "version": "3.45.26",
5
+ "version": "3.46.1",
6
6
  "main": "src/index.js",
7
7
  "exports": {
8
8
  ".": "./src/index.js",
@@ -128,6 +128,7 @@
128
128
  "@metascraper/helpers": "~5.52.0",
129
129
  "@microlink/mql": "~0.17.0",
130
130
  "@microlink/ping-url": "~1.4.16",
131
+ "bimi-url": "~1.0.0",
131
132
  "browserless": "~13.6.0",
132
133
  "cacheable-lookup": "~6.1.0",
133
134
  "data-uri-regex": "~0.1.4",
@@ -8,6 +8,7 @@ const isEmail = require('is-email-like')
8
8
  const pTimeout = require('p-timeout')
9
9
  const pAny = require('p-any')
10
10
 
11
+ const { RESERVED_ADDRESS_CODE } = require('../util/is-reserved-ip')
11
12
  const httpStatus = require('../util/http-status')
12
13
  const isIterable = require('../util/is-iterable')
13
14
  const ExtendableError = require('../util/error')
@@ -23,14 +24,28 @@ const getInputType = input => {
23
24
  return 'username'
24
25
  }
25
26
 
26
- const factory = ({ constants, providers, providersBy, reachableUrl }) => {
27
+ const factory = ({
28
+ constants,
29
+ providers,
30
+ providerTiers,
31
+ reachableUrl,
32
+ isReservedIp
33
+ }) => {
27
34
  const { REQUEST_TIMEOUT } = constants
28
- const providerEntriesByType = Object.fromEntries(
29
- Object.entries(providersBy).map(([inputType, providerNames]) => [
30
- inputType,
31
- providerNames.map(provider => [provider, providers[provider]])
32
- ])
33
- )
35
+
36
+ const refuseReservedAddress = provider => {
37
+ throw new ExtendableError({
38
+ message: 'The URL points to a reserved address.',
39
+ provider,
40
+ statusCode: httpStatus.FORBIDDEN
41
+ })
42
+ }
43
+
44
+ const assertPublicUrl = async (url, provider) => {
45
+ if (await isReservedIp(new URL(url).hostname)) {
46
+ refuseReservedAddress(provider)
47
+ }
48
+ }
34
49
 
35
50
  const getAvatarContent = provider => async output => {
36
51
  if (typeof output !== 'string' || output === '') {
@@ -55,7 +70,12 @@ const factory = ({ constants, providers, providersBy, reachableUrl }) => {
55
70
  })
56
71
  }
57
72
 
58
- const { statusCode, url } = await reachableUrl(output)
73
+ await assertPublicUrl(output, provider)
74
+
75
+ const { statusCode, url, reservedAddress } = await reachableUrl(output)
76
+
77
+ if (reservedAddress) refuseReservedAddress(provider)
78
+ await assertPublicUrl(url, provider)
59
79
 
60
80
  if (!reachableUrl.isReachable({ statusCode })) {
61
81
  throw new ExtendableError({
@@ -68,43 +88,69 @@ const factory = ({ constants, providers, providersBy, reachableUrl }) => {
68
88
  return { type: 'url', data: url, provider }
69
89
  }
70
90
 
71
- const getAvatar = async (fn, provider, input, context) => {
91
+ const getAvatar = async (
92
+ fn,
93
+ provider,
94
+ input,
95
+ context,
96
+ deadline = Date.now() + REQUEST_TIMEOUT
97
+ ) => {
72
98
  const promise = Promise.resolve(fn(input, context))
73
99
  .then(getAvatarContent(provider))
74
100
  .catch(error => {
75
101
  isIterable.forEach(error, error => {
102
+ if (error.code === RESERVED_ADDRESS_CODE) {
103
+ error.statusCode = httpStatus.FORBIDDEN
104
+ }
76
105
  error.statusCode = error.statusCode ?? error.response?.statusCode
77
106
  error.provider = provider
78
107
  })
79
108
  throw error
80
109
  })
81
110
 
82
- return pTimeout(promise, REQUEST_TIMEOUT).catch(error => {
83
- error.provider = provider
84
- throw error
85
- })
111
+ return pTimeout(promise, Math.max(0, deadline - Date.now())).catch(
112
+ error => {
113
+ error.provider = provider
114
+ throw error
115
+ }
116
+ )
86
117
  }
87
118
 
88
- const resolveAutoByType = async (inputType, input, context) => {
89
- let collection = providerEntriesByType[inputType]
119
+ const raceTier = (tier, input, context, deadline) =>
120
+ pAny(
121
+ tier.map(provider =>
122
+ getAvatar(providers[provider], provider, input, context, deadline)
123
+ )
124
+ )
90
125
 
91
- if (inputType === 'email' && isHash(input)) {
92
- collection = collection.filter(([provider]) => provider === 'gravatar')
126
+ const auto = inputType => async (input, context) => {
127
+ const tierKey =
128
+ inputType === 'email' && isHash(input) ? 'emailHash' : inputType
129
+ const tiers = providerTiers[tierKey]
130
+
131
+ if (!tiers?.length) {
132
+ throw new ExtendableError({
133
+ message: `No providers declared for \`${tierKey}\`.`,
134
+ statusCode: httpStatus.NOT_FOUND
135
+ })
93
136
  }
94
137
 
95
- const promises = new Array(collection.length)
138
+ const deadline = Date.now() + REQUEST_TIMEOUT
139
+
140
+ let firstError
96
141
 
97
- for (let index = 0; index < collection.length; index++) {
98
- const [provider, fn] = collection[index]
99
- promises[index] = getAvatar(fn, provider, input, context)
142
+ for (const tier of tiers) {
143
+ try {
144
+ return await raceTier(tier, input, context, deadline)
145
+ } catch (error) {
146
+ firstError ??= error
147
+ if (Date.now() >= deadline) break
148
+ }
100
149
  }
101
150
 
102
- return pAny(promises)
151
+ throw firstError
103
152
  }
104
153
 
105
- const auto = inputType => (input, context) =>
106
- resolveAutoByType(inputType, input, context)
107
-
108
154
  return { auto, getInputType, getAvatar }
109
155
  }
110
156
 
package/src/index.js CHANGED
@@ -22,12 +22,14 @@ module.exports = ({
22
22
  redis
23
23
  })
24
24
  const cache = require('./util/cache')({ createMultiCache, createRedisCache })
25
+ const dnsResolver = require('./util/dns-resolver')(constants)
25
26
  const cacheableLookup = require('./util/cacheable-lookup')({
26
27
  ...constants,
27
- cache: cache.dnsCache
28
+ cache: cache.dnsCache,
29
+ resolver: dnsResolver
28
30
  })
29
31
  const isReservedIp = require('./util/is-reserved-ip')({ cacheableLookup })
30
- const got = require('./util/got')({ cacheableLookup })
32
+ const got = require('./util/got')({ cacheableLookup, isReservedIp })
31
33
  const reachableUrl = require('./util/reachable-url')({
32
34
  got,
33
35
  pingCache: cache.pingCache
@@ -50,16 +52,20 @@ module.exports = ({
50
52
  got,
51
53
  reachableUrl,
52
54
  isReservedIp,
55
+ dnsResolver,
56
+ bimiCache: cache.bimiCache,
53
57
  githubSearchCache: cache.githubSearchCache,
54
58
  itunesSearchCache: cache.itunesSearchCache
55
59
  }
56
- const { providers, providersBy } = require('./providers')(providerCtx)
60
+ const { providers, providerTiers, providersBy } =
61
+ require('./providers')(providerCtx)
57
62
 
58
63
  const { auto, getInputType, getAvatar } = require('./avatar/auto')({
59
64
  constants,
60
65
  providers,
61
- providersBy,
62
- reachableUrl
66
+ providerTiers,
67
+ reachableUrl,
68
+ isReservedIp
63
69
  })
64
70
 
65
71
  const unavatar = input => auto(getInputType(input))(input, {})
@@ -0,0 +1,19 @@
1
+ 'use strict'
2
+
3
+ const createGetLogo = require('bimi-url')
4
+
5
+ const parseInput = input => input.slice(input.lastIndexOf('@') + 1)
6
+
7
+ module.exports = ({ bimiCache, dnsResolver, got }) => {
8
+ const getLogo = createGetLogo({
9
+ gotOpts: got.gotOpts,
10
+ keyvOpts: bimiCache,
11
+ resolveTxt: hostname => dnsResolver.resolveTxt(hostname)
12
+ })
13
+
14
+ return function bimi (input) {
15
+ return getLogo(parseInput(input))
16
+ }
17
+ }
18
+
19
+ module.exports.parseInput = parseInput
@@ -1,86 +1,99 @@
1
1
  'use strict'
2
2
 
3
- const providersBy = {
4
- email: ['gravatar', 'github'],
3
+ const providerTiers = {
4
+ email: [['gravatar', 'github'], ['bimi']],
5
+ emailHash: [['gravatar']],
5
6
  username: [
6
- 'apple-music',
7
- 'apple-store',
8
- 'behance',
9
- 'bilibili',
10
- 'bluesky',
11
- 'buymeacoffee',
12
- 'cashapp',
13
- 'codepen',
14
- 'cults3d',
15
- 'cursor',
16
- 'deezer',
17
- 'deviantart',
18
- 'discord',
19
- 'dockerhub',
20
- 'dribbble',
21
- 'facebook',
22
- 'flickr',
23
- 'gitee',
24
- 'github',
25
- 'gitlab',
26
- 'google-play',
27
- 'huggingface',
28
- 'hevy',
29
- 'instagram',
30
- 'juejin',
31
- 'ko-fi',
32
- 'linkedin',
33
- 'mastodon',
34
- 'medium',
35
- 'netease-music',
36
- 'npm',
37
- 'onlyfans',
38
- 'openstreetmap',
39
- 'patreon',
40
- 'paypal',
41
- 'pinterest',
42
- 'printables',
43
- 'primal',
44
- 'producthunt',
45
- 'psnprofiles',
46
- 'qq',
47
- 'raycast',
48
- 'reddit',
49
- 'revolut',
50
- 'snapchat',
51
- 'soundcloud',
52
- 'spotify',
53
- 'stackoverflow',
54
- 'steam',
55
- 'strava',
56
- 'substack',
57
- 'telegram',
58
- 'thingiverse',
59
- 'threads',
60
- 'tidal',
61
- 'tieba',
62
- 'tiktok',
63
- 'tumblr',
64
- 'twitch',
65
- 'venmo',
66
- 'vimeo',
67
- 'weibo',
68
- 'whatsapp',
69
- 'wise',
70
- 'x',
71
- 'xboxgamertag',
72
- 'youtube',
73
- 'zhihu'
7
+ [
8
+ 'apple-music',
9
+ 'apple-store',
10
+ 'behance',
11
+ 'bilibili',
12
+ 'bluesky',
13
+ 'buymeacoffee',
14
+ 'cashapp',
15
+ 'codepen',
16
+ 'cults3d',
17
+ 'cursor',
18
+ 'deezer',
19
+ 'deviantart',
20
+ 'discord',
21
+ 'dockerhub',
22
+ 'dribbble',
23
+ 'facebook',
24
+ 'flickr',
25
+ 'gitee',
26
+ 'github',
27
+ 'gitlab',
28
+ 'google-play',
29
+ 'huggingface',
30
+ 'hevy',
31
+ 'instagram',
32
+ 'juejin',
33
+ 'ko-fi',
34
+ 'linkedin',
35
+ 'mastodon',
36
+ 'medium',
37
+ 'netease-music',
38
+ 'npm',
39
+ 'onlyfans',
40
+ 'openstreetmap',
41
+ 'patreon',
42
+ 'paypal',
43
+ 'pinterest',
44
+ 'printables',
45
+ 'primal',
46
+ 'producthunt',
47
+ 'psnprofiles',
48
+ 'qq',
49
+ 'raycast',
50
+ 'reddit',
51
+ 'revolut',
52
+ 'snapchat',
53
+ 'soundcloud',
54
+ 'spotify',
55
+ 'stackoverflow',
56
+ 'steam',
57
+ 'strava',
58
+ 'substack',
59
+ 'telegram',
60
+ 'thingiverse',
61
+ 'threads',
62
+ 'tidal',
63
+ 'tieba',
64
+ 'tiktok',
65
+ 'tumblr',
66
+ 'twitch',
67
+ 'venmo',
68
+ 'vimeo',
69
+ 'weibo',
70
+ 'whatsapp',
71
+ 'wise',
72
+ 'x',
73
+ 'xboxgamertag',
74
+ 'youtube',
75
+ 'zhihu'
76
+ ]
74
77
  ],
75
- domain: ['duckduckgo', 'google', 'microlink']
78
+ domain: [['bimi'], ['duckduckgo', 'google', 'microlink']]
76
79
  }
77
80
 
81
+ const { emailHash, ...publicTiers } = providerTiers
82
+
83
+ const providersBy = Object.fromEntries(
84
+ Object.entries(publicTiers).map(([inputType, tiers]) => [
85
+ inputType,
86
+ tiers.flat()
87
+ ])
88
+ )
89
+
78
90
  module.exports = ctx => {
79
91
  const providers = {
80
92
  'apple-music': require('./apple-music')(ctx),
81
93
  'apple-store': require('./apple-store')(ctx),
82
94
  behance: require('./behance')(ctx),
83
95
  bilibili: require('./bilibili')(ctx),
96
+ bimi: require('./bimi')(ctx),
84
97
  bluesky: require('./bluesky')(ctx),
85
98
  buymeacoffee: require('./buymeacoffee')(ctx),
86
99
  cashapp: require('./cashapp')(ctx),
@@ -151,5 +164,5 @@ module.exports = ctx => {
151
164
  zhihu: require('./zhihu')(ctx)
152
165
  }
153
166
 
154
- return { providers, providersBy }
167
+ return { providers, providerTiers, providersBy }
155
168
  }
package/src/util/cache.js CHANGED
@@ -3,11 +3,21 @@
3
3
  const ms = require('ms')
4
4
 
5
5
  module.exports = ({ createMultiCache, createRedisCache }) => ({
6
- dnsCache: createMultiCache(createRedisCache({ namespace: 'dns', ttl: ms('1d') })),
6
+ bimiCache: createMultiCache(
7
+ createRedisCache({ namespace: 'bimi', ttl: ms('1d') })
8
+ ),
9
+ dnsCache: createMultiCache(
10
+ createRedisCache({ namespace: 'dns', ttl: ms('1d') })
11
+ ),
7
12
  githubSearchCache: createRedisCache({
8
13
  namespace: 'github-search',
9
14
  ttl: ms('1d')
10
15
  }),
11
- pingCache: createMultiCache(createRedisCache({ namespace: 'ping', ttl: ms('1d') })),
12
- itunesSearchCache: createRedisCache({ namespace: 'itunes-search', ttl: ms('7d') })
16
+ pingCache: createMultiCache(
17
+ createRedisCache({ namespace: 'ping', ttl: ms('1d') })
18
+ ),
19
+ itunesSearchCache: createRedisCache({
20
+ namespace: 'itunes-search',
21
+ ttl: ms('7d')
22
+ })
13
23
  })
@@ -1,22 +1,10 @@
1
1
  'use strict'
2
2
 
3
3
  const CacheableLookup = require('cacheable-lookup')
4
- const Tangerine = require('tangerine')
5
4
 
6
- module.exports = ({ TTL_DEFAULT, DNS_TIMEOUT, DNS_SERVERS, cache }) =>
5
+ module.exports = ({ TTL_DEFAULT, cache, resolver }) =>
7
6
  new CacheableLookup({
8
7
  maxTtl: TTL_DEFAULT,
9
8
  cache,
10
- resolver: new Tangerine(
11
- {
12
- cache: false,
13
- timeout: DNS_TIMEOUT,
14
- servers: DNS_SERVERS
15
- },
16
- require('got').extend({
17
- responseType: 'buffer',
18
- decompress: false,
19
- retry: 0
20
- })
21
- )
9
+ resolver
22
10
  })
@@ -0,0 +1,18 @@
1
+ 'use strict'
2
+
3
+ const Tangerine = require('tangerine')
4
+ const got = require('got')
5
+
6
+ module.exports = ({ DNS_TIMEOUT, DNS_SERVERS }) =>
7
+ new Tangerine(
8
+ {
9
+ cache: false,
10
+ timeout: DNS_TIMEOUT,
11
+ servers: DNS_SERVERS
12
+ },
13
+ got.extend({
14
+ responseType: 'buffer',
15
+ decompress: false,
16
+ retry: 0
17
+ })
18
+ )
package/src/util/got.js CHANGED
@@ -5,6 +5,8 @@ const tlsHook = require('https-tls/hook')
5
5
  const uaHints = require('ua-hints')
6
6
  const got = require('got')
7
7
 
8
+ const { RESERVED_ADDRESS_CODE } = require('./is-reserved-ip')
9
+
8
10
  const topUserAgents = require('top-user-agents')
9
11
  const randomUserAgent = uniqueRandomArray(topUserAgents)
10
12
 
@@ -36,11 +38,40 @@ const userAgentHook = options => {
36
38
  }
37
39
  }
38
40
 
39
- module.exports = ({ cacheableLookup }) => {
41
+ module.exports = ({ cacheableLookup, isReservedIp }) => {
42
+ /**
43
+ * The refused address is reported on `options.context` because a caller
44
+ * reading the outcome through `reachable-url` never sees the rejection: it is
45
+ * swallowed there and served back as a response.
46
+ *
47
+ * Only a context the caller owns is written: the one got defaults to is
48
+ * frozen and shared between requests, so asking to be told is what passing a
49
+ * context means.
50
+ *
51
+ * got replaces the error with a RequestError that keeps the message and the
52
+ * code, so the refusal is named by a code rather than by its type.
53
+ */
54
+ const reservedAddressHook = async options => {
55
+ const hostname = options.url?.hostname
56
+ if (hostname && (await isReservedIp(hostname))) {
57
+ if (Object.isExtensible(options.context)) {
58
+ options.context.reservedAddress = hostname
59
+ }
60
+ const error = new Error(
61
+ `Refusing to request a reserved address: ${hostname}`
62
+ )
63
+ error.code = RESERVED_ADDRESS_CODE
64
+ throw error
65
+ }
66
+ }
67
+
40
68
  const gotOpts = {
41
69
  dnsCache: cacheableLookup,
42
70
  https: { rejectUnauthorized: false },
43
- hooks: { beforeRequest: [userAgentHook, tlsHook] }
71
+ hooks: {
72
+ beforeRequest: [reservedAddressHook, userAgentHook, tlsHook],
73
+ beforeRedirect: [reservedAddressHook]
74
+ }
44
75
  }
45
76
 
46
77
  const instance = got.extend(gotOpts)
@@ -2,16 +2,16 @@
2
2
 
3
3
  const ip = require('ipaddr.js')
4
4
 
5
+ const unbracket = hostname =>
6
+ hostname.startsWith('[') && hostname.endsWith(']')
7
+ ? hostname.slice(1, -1)
8
+ : hostname
9
+
5
10
  module.exports = ({ cacheableLookup }) => {
6
11
  const getIpAddress = async hostname => {
7
12
  if (ip.IPv4.isIPv4(hostname)) return hostname
8
- if (
9
- hostname.startsWith('[') &&
10
- hostname.endsWith(']') &&
11
- ip.IPv6.isIPv6(hostname.slice(1, -1))
12
- ) {
13
- return hostname.slice(1, -1)
14
- }
13
+ const literal = unbracket(hostname)
14
+ if (ip.IPv6.isIPv6(literal)) return literal
15
15
  const { address } = await cacheableLookup.lookupAsync(hostname)
16
16
  return address
17
17
  }
@@ -21,3 +21,5 @@ module.exports = ({ cacheableLookup }) => {
21
21
  return ip.process(ipAddress).range() !== 'unicast'
22
22
  }
23
23
  }
24
+
25
+ module.exports.RESERVED_ADDRESS_CODE = 'ERESERVEDADDRESSRANGE'
@@ -7,11 +7,12 @@ module.exports = ({ got, pingCache }) => {
7
7
  value: ({ url, statusCode }) => ({ url, statusCode })
8
8
  })
9
9
 
10
- const reachableUrl = (url, opts) =>
11
- pingUrl(url, {
12
- ...got.gotOpts,
13
- ...opts
14
- })
10
+ const reachableUrl = async (url, opts) => {
11
+ const context = {}
12
+ const response = await pingUrl(url, { ...got.gotOpts, ...opts, context })
13
+ const { reservedAddress } = context
14
+ return reservedAddress ? { ...response, reservedAddress } : response
15
+ }
15
16
 
16
17
  reachableUrl.isReachable = createPingUrl.isReachable
17
18