@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.
- package/bin/activitypub-bot.js +5 -1
- package/lib/app.js +10 -3
- package/lib/bot.js +29 -2
- package/lib/bots/donothing.js +5 -14
- package/lib/routes/profile.js +18 -0
- package/lib/routes/proxy.js +50 -0
- package/lib/routes/user.js +69 -0
- package/lib/routes/webfinger.js +5 -0
- package/lib/urlformatter.js +9 -7
- package/package.json +1 -1
- package/web/profile.html +72 -0
package/bin/activitypub-bot.js
CHANGED
|
@@ -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
|
|
40
|
+
return this.#fullname
|
|
22
41
|
}
|
|
23
42
|
|
|
24
43
|
get description () {
|
|
25
|
-
return
|
|
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 () {
|
package/lib/bots/donothing.js
CHANGED
|
@@ -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,
|
|
12
|
-
|
|
13
|
-
|
|
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
|
package/lib/routes/user.js
CHANGED
|
@@ -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
|
package/lib/routes/webfinger.js
CHANGED
|
@@ -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
|
})
|
package/lib/urlformatter.js
CHANGED
|
@@ -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 (
|
|
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
package/web/profile.html
ADDED
|
@@ -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 = `← <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>
|