Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
- [Ko-fi](#ko-fi)
- [LinkedIn](#linkedin)
- [Mastodon](#mastodon)
- [Matrix](#matrix)
- [Medium](#medium)
- [Microlink](#microlink)
- [NetEase Music](#netease-music)
Expand Down Expand Up @@ -852,6 +853,12 @@ Get any Mastodon user's profile picture from any instance using the public accou

e.g., [unavatar.io/mastodon/kpwags@hachyderm.io](https://unavatar.io/mastodon/kpwags@hachyderm.io)

### Matrix

Get any Matrix user's profile picture from any homeserver using the public profile API. Pass the full user ID as `@user:server` (the leading `@` is optional); a bare `user` resolves on `matrix.org`. Homeservers that require authentication for profiles or media return no avatar.

e.g., [unavatar.io/matrix/@matthew:matrix.org](https://unavatar.io/matrix/@matthew:matrix.org)

### Medium

Get any Medium author's profile picture by their username.
Expand Down
2 changes: 2 additions & 0 deletions src/providers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const providerTiers = {
'ko-fi',
'linkedin',
'mastodon',
'matrix',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
'medium',
'netease-music',
'npm',
Expand Down Expand Up @@ -124,6 +125,7 @@ module.exports = ctx => {
'ko-fi': require('./ko-fi')(ctx),
linkedin: require('./linkedin')(ctx),
mastodon: require('./mastodon')(ctx),
matrix: require('./matrix')(ctx),
medium: require('./medium')(ctx),
microlink: require('./microlink')(ctx),
'netease-music': require('./netease-music')(ctx),
Expand Down
1 change: 1 addition & 0 deletions src/providers/mastodon.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ module.exports = ({ got, isReservedIp }) => {
}

module.exports.parseInput = parseInput
module.exports.isValidServer = isValidServer
91 changes: 91 additions & 0 deletions src/providers/matrix.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use strict'

const { isValidServer } = require('./mastodon')

const DEFAULT_SERVER = 'matrix.org'
const WELL_KNOWN_PATH = '/.well-known/matrix/client'
const PROFILE_PATH = '/_matrix/client/v3/profile'
const MEDIA_DOWNLOAD_PATH = '/_matrix/media/v3/download'
const MXC_RE = /^mxc:\/\/([a-zA-Z0-9[][\w.:[\]-]*)\/([\w-]+)$/

const parseInput = input => {
if (typeof input !== 'string') return

const cleaned = input.startsWith('@') ? input.slice(1) : input
const separatorIndex = cleaned.indexOf(':')
const localpart =
separatorIndex === -1 ? cleaned : cleaned.slice(0, separatorIndex)
const server =
separatorIndex === -1 ? DEFAULT_SERVER : cleaned.slice(separatorIndex + 1)

if (!localpart || localpart.includes('@') || localpart.includes('/')) return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if (!isValidServer(server)) return

return {
userId: `@${localpart}:${server}`,
server,
hostname: new URL(`https://${server}`).hostname
}
}

const parseHomeserverUrl = baseUrl => {
try {
const url = new URL(baseUrl)
if (url.protocol !== 'https:' && url.protocol !== 'http:') return
return url.origin + url.pathname.replace(/\/+$/, '')
} catch {}
}

const getProfileUrl = (homeserverUrl, userId) =>
`${homeserverUrl}${PROFILE_PATH}/${encodeURIComponent(userId)}/avatar_url`

const getMediaUrl = (homeserverUrl, mxcUrl) => {
const match = typeof mxcUrl === 'string' && MXC_RE.exec(mxcUrl)
if (!match) return
const [, origin, mediaId] = match
return `${homeserverUrl}${MEDIA_DOWNLOAD_PATH}/${origin}/${mediaId}`
}

module.exports = ({ got, isReservedIp }) => {
const getHomeserverUrl = async server => {
const fallback = `https://${server}`
const body = await got(`${fallback}${WELL_KNOWN_PATH}`, {
responseType: 'json',
throwHttpErrors: false
})
.then(response => response.body)
.catch(() => undefined)

const homeserverUrl = parseHomeserverUrl(body?.['m.homeserver']?.base_url)
if (!homeserverUrl) return fallback
if (await isReservedIp(new URL(homeserverUrl).hostname)) return
return homeserverUrl
}

const matrix = async function (input) {
const parsed = parseInput(input)
if (!parsed) return

const { userId, server, hostname } = parsed

if (await isReservedIp(hostname)) return

const homeserverUrl = await getHomeserverUrl(server)
if (!homeserverUrl) return

const { body, statusCode } = await got(
getProfileUrl(homeserverUrl, userId),
{ responseType: 'json', throwHttpErrors: false }
)

if (statusCode >= 400) return

Comment thread
coderabbitai[bot] marked this conversation as resolved.
return getMediaUrl(homeserverUrl, body?.avatar_url)
}

return matrix
}

module.exports.parseInput = parseInput
module.exports.getMediaUrl = getMediaUrl
21 changes: 20 additions & 1 deletion test/unit/providers/mastodon.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,26 @@
const test = require('ava')
const sinon = require('sinon')

const { parseInput } = require('../../../src/providers/mastodon')
const { parseInput, isValidServer } = require('../../../src/providers/mastodon')

test('isValidServer accepts bare hosts with optional port', t => {
t.true(isValidServer('mastodon.social'))
t.true(isValidServer('Mastodon.Social'))
t.true(isValidServer('example.org:8448'))
t.true(isValidServer('127.0.0.1'))
t.true(isValidServer('[::1]:8448'))
})

test('isValidServer rejects anything beyond a bare host', t => {
t.false(isValidServer(''))
t.false(isValidServer('user:pass@host'))
t.false(isValidServer('host/path'))
t.false(isValidServer('host?q=1'))
t.false(isValidServer('host#hash'))
t.false(isValidServer('host:443'))
t.false(isValidServer('host:99999'))
t.false(isValidServer('a@b'))
})

test('parses @user@server format', t => {
t.deepEqual(parseInput('@kiko@indieweb.social'), {
Expand Down
Loading
Loading