@evanp/activitypub-bot 0.31.1 → 0.32.0

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.
@@ -32,6 +32,7 @@ Options:
32
32
  --delivery <number> Number of background delivery workers
33
33
  --distribution <number> Number of background distribution workers
34
34
  --index-file <path> HTML page to show at root path
35
+ --profile-file <path> HTML page to show for bot profiles
35
36
  -h, --help Show this help
36
37
  `)
37
38
  process.exit(0)
@@ -47,6 +48,7 @@ const parseNumber = (value) => {
47
48
  const baseDir = dirname(fileURLToPath(import.meta.url))
48
49
  const DEFAULT_BOTS_CONFIG_FILE = resolve(baseDir, '..', 'bots', 'index.js')
49
50
  const DEFAULT_INDEX_FILE = resolve(baseDir, '..', 'web', 'index.html')
51
+ const DEFAULT_PROFILE_FILE = resolve(baseDir, '..', 'web', 'profile.html')
50
52
 
51
53
  const DATABASE_URL = normalize(values['database-url']) || process.env.DATABASE_URL || 'sqlite::memory:'
52
54
  const ORIGIN = normalize(values.origin) || process.env.ORIGIN || 'https://activitypubbot.test'
@@ -60,6 +62,7 @@ const LOG_LEVEL =
60
62
  const DELIVERY = parseNumber(values.delivery) || parseNumber(process.env.DELIVERY) || 2
61
63
  const DISTRIBUTION = parseNumber(values.distribution) || parseNumber(process.env.DISTRIBUTION) || 8
62
64
  const INDEX_FILE = values['index-file'] || process.env.INDEX_FILE || DEFAULT_INDEX_FILE
65
+ const PROFILE_FILE = values['profile-file'] || process.env.PROFILE_FILE || DEFAULT_PROFILE_FILE
63
66
 
64
67
  const bots = (await import(BOTS_CONFIG_FILE)).default
65
68
 
@@ -70,7 +73,8 @@ const app = await makeApp({
70
73
  logLevel: LOG_LEVEL,
71
74
  deliveryWorkerCount: DELIVERY,
72
75
  distributionWorkerCount: DISTRIBUTION,
73
- indexFileName: INDEX_FILE
76
+ indexFileName: INDEX_FILE,
77
+ profileFileName: PROFILE_FILE
74
78
  })
75
79
 
76
80
  const server = app.listen(parseInt(PORT), () => {
package/lib/app.js CHANGED
@@ -27,6 +27,8 @@ import inboxRouter from './routes/inbox.js'
27
27
  import healthRouter from './routes/health.js'
28
28
  import webfingerRouter from './routes/webfinger.js'
29
29
  import sharedInboxRouter from './routes/sharedinbox.js'
30
+ import profileRouter from './routes/profile.js'
31
+ import proxyRouter from './routes/proxy.js'
30
32
  import { BotContext } from './botcontext.js'
31
33
  import { Transformer } from './microsyntax.js'
32
34
  import { HTTPSignatureAuthenticator } from './httpsignatureauthenticator.js'
@@ -42,8 +44,9 @@ import DoNothingBot from './bots/donothing.js'
42
44
 
43
45
  const currentDir = dirname(fileURLToPath(import.meta.url))
44
46
  const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
47
+ const DEFAULT_PROFILE_FILENAME = resolve(currentDir, '..', 'web', 'profile.html')
45
48
 
46
- export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent', deliveryWorkerCount = 2, distributionWorkerCount = 8, indexFileName = DEFAULT_INDEX_FILENAME }) {
49
+ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent', deliveryWorkerCount = 2, distributionWorkerCount = 8, indexFileName = DEFAULT_INDEX_FILENAME, profileFileName = DEFAULT_PROFILE_FILENAME }) {
47
50
  const logger = Logger({
48
51
  level: logLevel
49
52
  })
@@ -188,7 +191,8 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
188
191
  origin,
189
192
  deliverer,
190
193
  deliveryWorkers,
191
- indexFileName
194
+ indexFileName,
195
+ profileFileName
192
196
  }
193
197
 
194
198
  app.use(HTTPLogger({
@@ -207,12 +211,15 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
207
211
  req.rawBodyText = buf.toString(encoding || 'utf8')
208
212
  }
209
213
  }))
214
+ app.use(express.urlencoded({ extended: true }))
210
215
 
211
216
  // These should not check HTTP Signature even if provided
212
217
 
213
- app.use('/', serverRouter)
214
218
  app.use('/', healthRouter)
219
+ app.use('/', serverRouter)
220
+ app.use('/', profileRouter)
215
221
  app.use('/', webfingerRouter)
222
+ app.use('/', proxyRouter)
216
223
 
217
224
  // These should check HTTP Signature if provided
218
225
 
package/lib/bot.js CHANGED
@@ -1,13 +1,32 @@
1
+ const DEFAULT_FULLNAME = 'Bot'
2
+ const DEFAULT_DESCRIPTION = 'Default bot'
3
+
1
4
  export default class Bot {
2
5
  #context = null
3
6
  #username = null
4
7
  #checkSignature
8
+ #fullname
9
+ #description
10
+ #icon
11
+ #image
5
12
 
6
13
  constructor (username, options = {}) {
7
14
  this.#username = username
8
15
  this.#checkSignature = ('checkSignature' in options)
9
16
  ? options.checkSignature
10
17
  : true
18
+ this.#fullname = ('fullname' in options)
19
+ ? options.fullname
20
+ : DEFAULT_FULLNAME
21
+ this.#description = ('description' in options)
22
+ ? options.description
23
+ : DEFAULT_DESCRIPTION
24
+ this.#icon = ('icon' in options)
25
+ ? options.icon
26
+ : null
27
+ this.#image = ('image' in options)
28
+ ? options.image
29
+ : null
11
30
  }
12
31
 
13
32
  async initialize (context) {
@@ -18,11 +37,19 @@ export default class Bot {
18
37
  }
19
38
 
20
39
  get fullname () {
21
- return 'Bot'
40
+ return this.#fullname
22
41
  }
23
42
 
24
43
  get description () {
25
- return 'A default, do-nothing bot.'
44
+ return this.#description
45
+ }
46
+
47
+ get icon () {
48
+ return this.#icon
49
+ }
50
+
51
+ get image () {
52
+ return this.#image
26
53
  }
27
54
 
28
55
  get username () {
@@ -4,20 +4,11 @@ const DEFAULT_FULLNAME = 'Do Nothing Bot'
4
4
  const DEFAULT_DESCRIPTION = 'A bot that does nothing.'
5
5
 
6
6
  export default class DoNothingBot extends Bot {
7
- #fullname
8
- #description
9
-
10
7
  constructor (username, options = {}) {
11
- super(username, options)
12
- this.#fullname = options.fullname || DEFAULT_FULLNAME
13
- this.#description = options.description || DEFAULT_DESCRIPTION
14
- }
15
-
16
- get fullname () {
17
- return this.#fullname
18
- }
19
-
20
- get description () {
21
- return this.#description
8
+ super(username, {
9
+ fullname: DEFAULT_FULLNAME,
10
+ description: DEFAULT_DESCRIPTION,
11
+ ...options
12
+ })
22
13
  }
23
14
  }
@@ -0,0 +1,18 @@
1
+ import express from 'express'
2
+ import createHttpError from 'http-errors'
3
+ import BotMaker from '../botmaker.js'
4
+
5
+ const router = express.Router()
6
+
7
+ router.get('/profile/:username', async (req, res, next) => {
8
+ const { username } = req.params
9
+ const { profileFileName, bots } = req.app.locals
10
+ const bot = await BotMaker.makeBot(bots, username)
11
+ if (!bot) {
12
+ return next(createHttpError(404, `User ${username} not found`))
13
+ }
14
+ res.type('html')
15
+ res.sendFile(profileFileName)
16
+ })
17
+
18
+ export default router
@@ -0,0 +1,50 @@
1
+ import express from 'express'
2
+
3
+ import as2 from '../activitystreams.js'
4
+ import createHttpError from 'http-errors'
5
+
6
+ const router = express.Router()
7
+
8
+ router.options('/shared/proxy', (req, res) => {
9
+ const { origin } = req.app.locals
10
+ res.set('Allow', 'POST')
11
+ res.set('Access-Control-Allow-Origin', origin)
12
+ res.status(200).end()
13
+ })
14
+
15
+ router.post('/shared/proxy', async (req, res, next) => {
16
+ const { client, logger, origin } = req.app.locals
17
+ const { id } = req.body
18
+
19
+ if (!id) {
20
+ return next(createHttpError(400, 'Missing id parameter'))
21
+ }
22
+
23
+ let url
24
+
25
+ try {
26
+ url = new URL(id)
27
+ } catch (error) {
28
+ return next(createHttpError(400, 'id must be an URL'))
29
+ }
30
+
31
+ if (url.protocol !== 'https:') {
32
+ return next(createHttpError(400, 'id must be an https: URL'))
33
+ }
34
+
35
+ let obj
36
+
37
+ try {
38
+ obj = await client.get(id)
39
+ } catch (err) {
40
+ logger.warn({ err, id }, 'Error fetching object in proxy')
41
+ return next(createHttpError(400, `Error fetching object ${id}`))
42
+ }
43
+
44
+ res.status(200)
45
+ res.set('Access-Control-Allow-Origin', origin)
46
+ res.type(as2.mediaType)
47
+ res.end(await obj.write())
48
+ })
49
+
50
+ export default router
@@ -1,3 +1,4 @@
1
+ import { fileURLToPath } from 'node:url'
1
2
  import express from 'express'
2
3
  import as2 from '../activitystreams.js'
3
4
  import createHttpError from 'http-errors'
@@ -5,6 +6,23 @@ import BotMaker from '../botmaker.js'
5
6
 
6
7
  const router = express.Router()
7
8
 
9
+ async function toLink (url, formatter, username, type) {
10
+ if (!url) {
11
+ return null
12
+ }
13
+ if (url.protocol === 'file:') {
14
+ return {
15
+ href: formatter.format({ username, type }),
16
+ type: 'Link'
17
+ }
18
+ } else {
19
+ return {
20
+ href: url.href,
21
+ type: 'Link'
22
+ }
23
+ }
24
+ }
25
+
8
26
  router.get('/user/:username', async (req, res, next) => {
9
27
  const { username } = req.params
10
28
  const { actorStorage, keyStorage, formatter, bots, origin } = req.app.locals
@@ -34,6 +52,13 @@ router.get('/user/:username', async (req, res, next) => {
34
52
  },
35
53
  endpoints: {
36
54
  sharedInbox: `${origin}/shared/inbox`
55
+ },
56
+ icon: await toLink(bot.icon, formatter, username, 'icon'),
57
+ image: await toLink(bot.image, formatter, username, 'image'),
58
+ url: {
59
+ href: formatter.format({ username, type: 'profile' }),
60
+ type: 'Link',
61
+ mediaType: 'text/html'
37
62
  }
38
63
  })
39
64
  res.status(200)
@@ -71,4 +96,48 @@ router.get('/user/:username/publickey', async (req, res, next) => {
71
96
  res.end(body)
72
97
  })
73
98
 
99
+ router.get('/user/:username/icon', async (req, res, next) => {
100
+ const { username } = req.params
101
+ const { bots } = req.app.locals
102
+ const bot = await BotMaker.makeBot(bots, username)
103
+ if (!bot) {
104
+ return next(createHttpError(404, `User ${username} not found`))
105
+ }
106
+ if (!bot.icon) {
107
+ return next(createHttpError(404, `No icon for ${username} found`))
108
+ }
109
+
110
+ if (typeof bot.icon !== 'object' || !(bot.icon instanceof URL)) {
111
+ return next(createHttpError(500, 'Incorrect image format from bot'))
112
+ }
113
+
114
+ if (bot.icon.protocol === 'file:') {
115
+ res.sendFile(fileURLToPath(bot.icon))
116
+ } else {
117
+ res.redirect(307, bot.icon)
118
+ }
119
+ })
120
+
121
+ router.get('/user/:username/image', async (req, res, next) => {
122
+ const { username } = req.params
123
+ const { bots } = req.app.locals
124
+ const bot = await BotMaker.makeBot(bots, username)
125
+ if (!bot) {
126
+ return next(createHttpError(404, `User ${username} not found`))
127
+ }
128
+ if (!bot.image) {
129
+ return next(createHttpError(404, `No image for ${username} found`))
130
+ }
131
+
132
+ if (typeof bot.image !== 'object' || !(bot.image instanceof URL)) {
133
+ return next(createHttpError(500, 'Incorrect image format from bot'))
134
+ }
135
+
136
+ if (bot.image.protocol === 'file:') {
137
+ res.sendFile(fileURLToPath(bot.image))
138
+ } else {
139
+ res.redirect(307, bot.image)
140
+ }
141
+ })
142
+
74
143
  export default router
@@ -23,6 +23,11 @@ async function botWebfinger (username, req, res, next) {
23
23
  rel: 'self',
24
24
  type: 'application/activity+json',
25
25
  href: formatter.format({ username })
26
+ },
27
+ {
28
+ rel: 'http://webfinger.net/rel/profile-page',
29
+ type: 'text/html',
30
+ href: formatter.format({ username, type: 'profile' })
26
31
  }
27
32
  ]
28
33
  })
@@ -15,6 +15,8 @@ export class UrlFormatter {
15
15
  let base = null
16
16
  if (server) {
17
17
  return this.format({ username: this.#hostname, type, nanoid, collection, page, server: false })
18
+ } else if (username && type && type === 'profile') {
19
+ return `${this.#origin}/profile/${username}`
18
20
  } else if (username) {
19
21
  base = `${this.#origin}/user/${username}`
20
22
  } else {
@@ -22,10 +24,10 @@ export class UrlFormatter {
22
24
  }
23
25
  let major = null
24
26
  if (type) {
25
- if (nanoid) {
26
- major = `${base}/${type.toLowerCase()}/${nanoid}`
27
- } else if (type === 'publickey') {
27
+ if (['publickey', 'icon', 'image'].includes(type)) {
28
28
  major = `${base}/${type.toLowerCase()}`
29
+ } else if (nanoid) {
30
+ major = `${base}/${type.toLowerCase()}/${nanoid}`
29
31
  } else {
30
32
  throw new Error('Cannot format URL without nanoid')
31
33
  }
@@ -42,10 +44,6 @@ export class UrlFormatter {
42
44
  } else {
43
45
  url = major
44
46
  }
45
- // For the base case, we want a trailing slash.
46
- if (url === this.#origin) {
47
- url = `${url}/actor`
48
- }
49
47
  return url
50
48
  }
51
49
 
@@ -72,6 +70,10 @@ export class UrlFormatter {
72
70
  parts.username = pathParts[1]
73
71
  pathParts = pathParts.slice(2)
74
72
  parts.server = (parts.username === this.#hostname)
73
+ } else if (pathParts.length > 0 && pathParts[0] === 'profile') {
74
+ parts.username = pathParts[1]
75
+ parts.type = 'profile'
76
+ return parts
75
77
  }
76
78
  if (pathParts.length > 0) {
77
79
  if (USER_COLLECTIONS.includes(pathParts[0])) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.31.1",
3
+ "version": "0.32.0",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -0,0 +1,72 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>activitypub.bot</title>
8
+ <script type="module" src="https://unpkg.com/@socialwebfoundation/ap-components"></script>
9
+ <style>
10
+ body { max-width: 60ch; margin: 0 auto; padding: 1rem; }
11
+ </style>
12
+ </head>
13
+
14
+ <body>
15
+ <p id="breadcrumb"></p>
16
+ <main>
17
+ <section id="account"></section>
18
+ </main>
19
+
20
+ <script type="module">
21
+ import { ActivityPubElement } from "https://unpkg.com/@socialwebfoundation/ap-components";
22
+
23
+ const cache = new Map();
24
+
25
+ function wrap(json) {
26
+ return {
27
+ ok: true,
28
+ async json() {
29
+ return json
30
+ }
31
+ }
32
+ }
33
+
34
+ function addObjectIdLink(objectId) {
35
+ const link = document.createElement('link')
36
+ link.rel = 'alternate'
37
+ link.type = 'application/activity+json'
38
+ link.href = objectId
39
+ document.head.appendChild(link)
40
+ }
41
+
42
+ window.onload = () => {
43
+ const page = document.location
44
+ ActivityPubElement.fetchFunction = async (url, options) => {
45
+ const u = new URL(url)
46
+ if (u.origin == page.origin) {
47
+ return await fetch(u, options)
48
+ } else {
49
+ return await fetch('/shared/proxy', {
50
+ method: "POST",
51
+ headers: {
52
+ "Content-Type": "application/x-www-form-urlencoded"
53
+ },
54
+ body: new URLSearchParams({ id: url }),
55
+ });
56
+ }
57
+ }
58
+
59
+ document.getElementById('breadcrumb').innerHTML = `&larr; <a href="${page.origin}/">${page.host}</a>`
60
+
61
+ const username = page.pathname.split('/')[2]
62
+ const objectId = `${page.origin}/user/${username}`
63
+ addObjectIdLink(objectId)
64
+ const account = document.getElementById("account");
65
+ customElements.whenDefined('ap-actor-page').then(() => {
66
+ account.innerHTML = `<ap-actor-page object-id="${objectId}"></ap-actor-page>`;
67
+ });
68
+ };
69
+ </script>
70
+ </body>
71
+
72
+ </html>