diff --git a/CHANGELOG.md b/CHANGELOG.md index 5728c63beb..45ae952b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,47 @@ +#### v4.4.1 (2025-05-16) + +##### Chores + +* up themes (61a63851) +* incrementing version number - v4.4.0 (0a75eee3) +* update changelog for v4.4.0 (09cc91d5) +* incrementing version number - v4.3.2 (b92b5d80) +* incrementing version number - v4.3.1 (308e6b9f) +* incrementing version number - v4.3.0 (bff291db) +* incrementing version number - v4.2.2 (17fecc24) +* incrementing version number - v4.2.1 (852a270c) +* incrementing version number - v4.2.0 (87581958) +* incrementing version number - v4.1.1 (b2afbb16) +* incrementing version number - v4.1.0 (36c80850) +* incrementing version number - v4.0.6 (4a52fb2e) +* incrementing version number - v4.0.5 (1792a62b) +* incrementing version number - v4.0.4 (b1125cce) +* incrementing version number - v4.0.3 (2b65c735) +* incrementing version number - v4.0.2 (73fe5fcf) +* incrementing version number - v4.0.1 (a461b758) +* incrementing version number - v4.0.0 (c1eaee45) + +##### New Features + +* save width and height values into post attachment (3674fa57) +* use local date string for digest subject (3d96afb2) + +##### Bug Fixes + +* openapi schema to handle additional `attachments` field in postsobject (ce5ef1ab) +* group edit url (0a574d72) +* add attachments to getpostsummaries call in search, #13324 (8f9f3771) +* bring back auto-categorization if group and object are same-origin, handle Peertube putting channel names in `attributedTo` (a460a550) +* #13419, handle remote content with mediaType text/markdown (45a11d45) + +##### Refactors + +* create date once per digest.send (6c3e2a8e) + +##### Tests + +* fix tests to account for a460a55064e1280f36a0021e0510c7c557251030 (948bfe46) + #### v4.4.0 (2025-05-14) ##### Breaking Changes diff --git a/install/package.json b/install/package.json index abdbfcc2c0..29c2898f4f 100644 --- a/install/package.json +++ b/install/package.json @@ -99,7 +99,7 @@ "nconf": "0.13.0", "nodebb-plugin-2factor": "7.5.10", "nodebb-plugin-composer-default": "10.2.50", - "nodebb-plugin-dbsearch": "6.2.16", + "nodebb-plugin-dbsearch": "6.2.19", "nodebb-plugin-emoji": "6.0.2", "nodebb-plugin-emoji-android": "4.1.1", "nodebb-plugin-markdown": "13.2.1", @@ -107,7 +107,7 @@ "nodebb-plugin-spam-be-gone": "2.3.2", "nodebb-plugin-web-push": "0.7.4", "nodebb-rewards-essentials": "1.0.2", - "nodebb-theme-harmony": "2.1.13", + "nodebb-theme-harmony": "2.1.15", "nodebb-theme-lavender": "7.1.19", "nodebb-theme-peace": "2.2.43", "nodebb-theme-persona": "14.1.12", @@ -162,8 +162,8 @@ "@commitlint/config-angular": "19.8.1", "coveralls": "3.1.1", "@eslint/js": "9.26.0", - "@stylistic/eslint-plugin-js": "4.2.0", - "eslint-config-nodebb": "1.1.4", + "@stylistic/eslint-plugin-js": "4.4.0", + "eslint-config-nodebb": "1.1.5", "eslint-plugin-import": "2.31.0", "grunt": "1.6.1", "grunt-contrib-watch": "1.1.0", diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index 82a623ea32..ec51a88ead 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -271,6 +271,7 @@ "invalid-plugin-id": "Invalid plugin ID", "plugin-not-whitelisted": "Unable to install plugin – only plugins whitelisted by the NodeBB Package Manager can be installed via the ACP", + "cannot-toggle-system-plugin": "You cannot toggle the state of a system plugin", "plugin-installation-via-acp-disabled": "Plugin installation via ACP is disabled", "plugins-set-in-configuration": "You are not allowed to change plugin state as they are defined at runtime (config.json, environmental variables or terminal arguments), please modify the configuration instead.", "theme-not-set-in-configuration": "When defining active plugins in configuration, changing themes requires adding the new theme to the list of active plugins before updating it in the ACP", diff --git a/public/scss/generics.scss b/public/scss/generics.scss index 808d427ba5..0938613ec9 100644 --- a/public/scss/generics.scss +++ b/public/scss/generics.scss @@ -23,11 +23,13 @@ display: block; } } -.dropdown-left { - .dropdown-menu { --bs-position: start; } +html[data-dir="ltr"] { + .dropdown-left .dropdown-menu { --bs-position: start; } + .dropdown-right .dropdown-menu { --bs-position: end; } } -.dropdown-right { - .dropdown-menu { --bs-position: end; } +html[data-dir="rtl"] { + .dropdown-left .dropdown-menu { --bs-position: end; } + .dropdown-right .dropdown-menu { --bs-position: start; } } .category-dropdown-menu { diff --git a/public/src/client/topic/threadTools.js b/public/src/client/topic/threadTools.js index a83a9d86b5..7a2519b069 100644 --- a/public/src/client/topic/threadTools.js +++ b/public/src/client/topic/threadTools.js @@ -225,7 +225,7 @@ define('forum/topic/threadTools', [ return; } dropdownMenu.html(helpers.generatePlaceholderWave([8, 8, 8])); - const data = await socket.emit('topics.loadTopicTools', { tid: ajaxify.data.tid, cid: ajaxify.data.cid }); + const data = await socket.emit('topics.loadTopicTools', { tid: ajaxify.data.tid, cid: ajaxify.data.cid }).catch(alerts.error); const html = await app.parseAndTranslate('partials/topic/topic-menu-list', data); $(dropdownMenu).attr('data-loaded', 'true').html(html); hooks.fire('action:topic.tools.load', { diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js index ab02cef6db..ec2f9d7fb0 100644 --- a/src/activitypub/inbox.js +++ b/src/activitypub/inbox.js @@ -95,6 +95,12 @@ inbox.update = async (req) => { try { switch (true) { case isNote: { + const cid = await posts.getCidByPid(object.id); + const allowed = await privileges.categories.can('posts:edit', cid, activitypub._constants.uid); + if (!allowed) { + throw new Error('[[error:no-privileges]]'); + } + const postData = await activitypub.mocks.post(object); postData.tags = await activitypub.notes._normalizeTags(postData._activitypub.tag, postData.cid); await posts.edit(postData); @@ -200,7 +206,7 @@ inbox.delete = async (req) => { const objectHostname = new URL(pid).hostname; if (actorHostname !== objectHostname) { - throw new Error('[[error:activitypub.origin-mismatch]]'); + return reject('Delete', object, actor); } const [isNote/* , isActor */] = await Promise.all([ @@ -210,6 +216,12 @@ inbox.delete = async (req) => { switch (true) { case isNote: { + const cid = await posts.getCidByPid(pid); + const allowed = await privileges.categories.can('posts:edit', cid, activitypub._constants.uid); + if (!allowed) { + return reject('Delete', object, actor); + } + const uid = await posts.getPostField(pid, 'uid'); await activitypub.feps.announce(pid, req.body); await api.posts[method]({ uid }, { pid }); @@ -282,9 +294,13 @@ inbox.announce = async (req) => { const { id: localId } = await activitypub.helpers.resolveLocalId(id); const exists = await posts.exists(localId || id); if (exists) { - const result = await posts.upvote(localId || id, object.actor); - if (localId) { - socketHelpers.upvote(result, 'notifications:upvoted-your-post-in'); + try { + const result = await posts.upvote(localId || id, object.actor); + if (localId) { + socketHelpers.upvote(result, 'notifications:upvoted-your-post-in'); + } + } catch (e) { + // vote denied due to local limitations (frequency, privilege, etc.); noop. } } @@ -530,7 +546,8 @@ inbox.undo = async (req) => { case 'Like': { const exists = await posts.exists(id); if (localType !== 'post' || !exists) { - throw new Error('[[error:invalid-pid]]'); + reject('Like', object, actor); + break; } const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid); diff --git a/src/activitypub/index.js b/src/activitypub/index.js index c87a0654f3..4067405080 100644 --- a/src/activitypub/index.js +++ b/src/activitypub/index.js @@ -27,6 +27,9 @@ const probeCache = ttl({ max: 500, ttl: 1000 * 60 * 60, // 1 hour }); +const probeRateLimit = ttl({ + ttl: 1000 * 3, // 3 seconds +}); const ActivityPub = module.exports; @@ -506,6 +509,13 @@ ActivityPub.probe = async ({ uid, url }) => { * - Returns a relative path if already available, true if not, and false otherwise. */ + // Disable on config setting; restrict lookups to HTTPS-enabled URLs only + const { activitypubProbe } = meta.config; + const { protocol } = new URL(url); + if (!activitypubProbe || protocol !== 'https:') { + return false; + } + // Known resources const [isNote, isMessage, isActor, isActorUrl] = await Promise.all([ posts.exists(url), @@ -541,6 +551,17 @@ ActivityPub.probe = async ({ uid, url }) => { } } + // Guests not allowed to use expensive logic path + if (!uid) { + return false; + } + + // One request allowed every 3 seconds (configured at top) + const limited = probeRateLimit.get(uid); + if (limited) { + return false; + } + // Cached result if (probeCache.has(url)) { return probeCache.get(url); @@ -572,6 +593,7 @@ ActivityPub.probe = async ({ uid, url }) => { return false; } try { + probeRateLimit.set(uid, true); return await checkHeader(meta.config.activitypubProbeTimeout || 2000); } catch (e) { if (e.name === 'TimeoutError') { diff --git a/src/activitypub/notes.js b/src/activitypub/notes.js index 616fc3a44f..64c6dcd53f 100644 --- a/src/activitypub/notes.js +++ b/src/activitypub/notes.js @@ -196,8 +196,8 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => { const { to, cc, attachment } = mainPost._activitypub; const tags = await Notes._normalizeTags(mainPost._activitypub.tag || []); - await Promise.all([ - topics.post({ + try { + await topics.post({ tid, uid: authorId, cid: options.cid || cid, @@ -208,13 +208,16 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => { content: mainPost.content, sourceContent: mainPost.sourceContent, _activitypub: mainPost._activitypub, - }), - Notes.updateLocalRecipients(mainPid, { to, cc }), - ]); - unprocessed.shift(); + }); + unprocessed.shift(); + } catch (e) { + activitypub.helpers.log(`[activitypub/notes.assert] Could not post topic (${mainPost.pid}): ${e.message}`); + return null; + } // These must come after topic is posted await Promise.all([ + Notes.updateLocalRecipients(mainPid, { to, cc }), mainPost._activitypub.image ? topics.thumbs.associate({ id: tid, path: mainPost._activitypub.image, diff --git a/src/api/activitypub.js b/src/api/activitypub.js index ce78f12d23..1f074f6776 100644 --- a/src/api/activitypub.js +++ b/src/api/activitypub.js @@ -119,6 +119,7 @@ activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => { await activitypub.send(type, id, [actor], { id: `${nconf.get('url')}/${type}/${id}#activity/undo:follow/${encodeURIComponent(actor)}/${timestamp}`, type: 'Undo', + actor: object.actor, object, }); @@ -126,6 +127,7 @@ activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => { await Promise.all([ db.sortedSetRemove(`followingRemote:${id}`, actor), db.sortedSetRemove(`followRequests:uid.${id}`, actor), + db.sortedSetRemove(`followersRemote:${actor}`, id), db.decrObjectField(`user:${id}`, 'followingRemoteCount'), ]); } else if (type === 'cid') { diff --git a/src/controllers/activitypub/index.js b/src/controllers/activitypub/index.js index 36054d6616..de478a6021 100644 --- a/src/controllers/activitypub/index.js +++ b/src/controllers/activitypub/index.js @@ -31,7 +31,7 @@ Controller.fetch = async (req, res, next) => { if (typeof result === 'string') { return helpers.redirect(res, result); } else if (result) { - const { id, type } = await activitypub.get('uid', req.uid || 0, url.href); + const { id, type } = await activitypub.get('uid', req.uid, url.href); switch (true) { case activitypub._constants.acceptedPostTypes.includes(type): { return helpers.redirect(res, `/post/${encodeURIComponent(id)}`); @@ -145,7 +145,7 @@ Controller.postInbox = async (req, res) => { const method = String(req.body.type).toLowerCase(); if (!activitypub.inbox.hasOwnProperty(method)) { winston.warn(`[activitypub/inbox] Received Activity of type ${method} but unable to handle. Ignoring.`); - return res.sendStatus(501); + return res.sendStatus(200); } try { diff --git a/src/middleware/render.js b/src/middleware/render.js index e01110936f..9741585428 100644 --- a/src/middleware/render.js +++ b/src/middleware/render.js @@ -150,6 +150,7 @@ module.exports = function (middleware) { async function loadClientHeaderFooterData(req, res, options) { const registrationType = meta.config.registrationType || 'normal'; res.locals.config = res.locals.config || {}; + const userLang = res.locals.config.userLang || meta.config.userLang || 'en-GB'; const templateValues = { title: meta.config.title || '', 'title:url': meta.config['title:url'] || '', @@ -180,9 +181,9 @@ module.exports = function (middleware) { blocks: user.blocks.list(req.uid), user: user.getUserData(req.uid), isEmailConfirmSent: req.uid <= 0 ? false : await user.email.isValidationPending(req.uid), - languageDirection: translator.translate('[[language:dir]]', res.locals.config.userLang), - timeagoCode: languages.userTimeagoCode(res.locals.config.userLang), - browserTitle: translator.translate(controllersHelpers.buildTitle(title)), + languageDirection: translator.translate('[[language:dir]]', userLang), + timeagoCode: languages.userTimeagoCode(userLang), + browserTitle: translator.translate(controllersHelpers.buildTitle(title), userLang), navigation: navigation.get(req.uid), roomIds: req.uid > 0 ? db.getSortedSetRevRange(`uid:${req.uid}:chat:rooms`, 0, 0) : [], }); diff --git a/src/plugins/install.js b/src/plugins/install.js index 21d993226d..f3da1c3fb8 100644 --- a/src/plugins/install.js +++ b/src/plugins/install.js @@ -156,6 +156,16 @@ module.exports = function (Plugins) { } }; + Plugins.isSystemPlugin = async function (id) { + const pluginDir = path.join(paths.nodeModules, id, 'plugin.json'); + try { + const pluginData = JSON.parse(await fs.readFile(pluginDir, 'utf8')); + return pluginData && pluginData.system === true; + } catch (err) { + return false; + } + }; + Plugins.isActive = async function (id) { if (nconf.get('plugins:active')) { return nconf.get('plugins:active').includes(id); diff --git a/src/posts/queue.js b/src/posts/queue.js index cc3b1078c8..9f6b21636d 100644 --- a/src/posts/queue.js +++ b/src/posts/queue.js @@ -188,13 +188,16 @@ module.exports = function (Posts) { data: data, }; payload = await plugins.hooks.fire('filter:post-queue.save', payload); - payload.data = JSON.stringify(data); await db.sortedSetAdd('post:queue', now, id); - await db.setObject(`post:queue:${id}`, payload); + await db.setObject(`post:queue:${id}`, { + ...payload, + data: JSON.stringify(payload.data), + }); await user.setUserField(data.uid, 'lastqueuetime', now); cache.del('post-queue'); + await plugins.hooks.fire('action:post-queue.save', payload); const cid = await getCid(type, data); const uids = await getNotificationUids(cid); const bodyLong = await parseBodyLong(cid, type, data); diff --git a/src/socket.io/admin/plugins.js b/src/socket.io/admin/plugins.js index 4489be42bd..32f5ff61d0 100644 --- a/src/socket.io/admin/plugins.js +++ b/src/socket.io/admin/plugins.js @@ -11,6 +11,9 @@ const { pluginNamePattern } = require('../../constants'); const Plugins = module.exports; Plugins.toggleActive = async function (socket, plugin_id) { + if (await plugins.isSystemPlugin(plugin_id)) { + throw new Error('[[error:cannot-toggle-system-plugin]]'); + } postsCache.reset(); const data = await plugins.toggleActive(plugin_id); await events.log({ @@ -22,6 +25,9 @@ Plugins.toggleActive = async function (socket, plugin_id) { }; Plugins.toggleInstall = async function (socket, data) { + if (await plugins.isSystemPlugin(data.id)) { + throw new Error('[[error:cannot-toggle-system-plugin]]'); + } const isInstalled = await plugins.isInstalled(data.id); const isStarterPlan = nconf.get('saas_plan') === 'starter'; if ((isStarterPlan || nconf.get('acpPluginInstallDisabled')) && !isInstalled) { diff --git a/src/socket.io/topics/tools.js b/src/socket.io/topics/tools.js index 80bab41662..e4044a5272 100644 --- a/src/socket.io/topics/tools.js +++ b/src/socket.io/topics/tools.js @@ -6,9 +6,6 @@ const plugins = require('../../plugins'); module.exports = function (SocketTopics) { SocketTopics.loadTopicTools = async function (socket, data) { - if (!socket.uid) { - throw new Error('[[error:no-privileges]]'); - } if (!data) { throw new Error('[[error:invalid-data]]'); } @@ -21,7 +18,7 @@ module.exports = function (SocketTopics) { if (!topicData) { throw new Error('[[error:no-topic]]'); } - if (!userPrivileges['topics:read']) { + if (!userPrivileges['topics:read'] || !userPrivileges.view_thread_tools) { throw new Error('[[error:no-privileges]]'); } topicData.privileges = userPrivileges; diff --git a/test/activitypub/helpers.js b/test/activitypub/helpers.js index 2690910e06..3bad2f7002 100644 --- a/test/activitypub/helpers.js +++ b/test/activitypub/helpers.js @@ -202,3 +202,26 @@ Helpers.mocks.update = (override = {}) => { return { activity }; }; +Helpers.mocks.delete = (override = {}) => { + let actor = override.actor; + let object = override.object; + if (!actor) { + ({ id: actor } = Helpers.mocks.person()); + } + if (!object) { + ({ id: object } = Helpers.mocks.note()); + } + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${Helpers.mocks._baseUrl}/delete/${encodeURIComponent(object.id || object)}`, + type: 'Delete', + to: [activitypub._constants.publicAddress], + cc: [`${actor}/followers`], + actor, + object, + }; + + return { activity }; +}; + diff --git a/test/activitypub/privileges.js b/test/activitypub/privileges.js new file mode 100644 index 0000000000..1c8d2ec7f2 --- /dev/null +++ b/test/activitypub/privileges.js @@ -0,0 +1,206 @@ +'use strict'; + +const assert = require('assert'); +const nconf = require('nconf'); + +const db = require('../mocks/databasemock'); +const request = require('../../src/request'); +const user = require('../../src/user'); +const topics = require('../../src/topics'); +const posts = require('../../src/posts'); +const categories = require('../../src/categories'); +const privileges = require('../../src/privileges'); +const meta = require('../../src/meta'); +const install = require('../../src/install'); +const utils = require('../../src/utils'); +const activitypub = require('../../src/activitypub'); + +const helpers = require('./helpers'); + +describe('Privilege logic for remote users/content (ActivityPub)', () => { + before(async () => { + meta.config.activitypubEnabled = 1; + // await install.giveWorldPrivileges(); + }); + + describe('"fediverse" pseudo-user', () => { + describe('no privileges given', () => { + let uid; + let cid; + let topicData; + let postData; + let mainPid; + let handle; + + before(async () => { + uid = await user.create({ username: utils.generateUUID() }); + ({ cid } = await categories.create({ name: utils.generateUUID() })); + ({ topicData, postData } = await topics.post({ + cid, + uid, + title: utils.generateUUID(), + content: utils.generateUUID(), + })); + handle = await categories.getCategoryField(cid, 'handle'); + const privsToRemove = await privileges.categories.getGroupPrivilegeList(); + await privileges.categories.rescind(privsToRemove, cid, ['fediverse']); + }); + + describe('incoming requests', () => { + it('should not respond to a webfinger request to a category\'s handle', async () => { + const response = await activitypub.helpers.query(`${handle}@${nconf.get('url_parsed').hostname}`); + assert.strictEqual(response, false); + }); + + it('should not respond to a request for the category actor', async () => { + await assert.rejects( + activitypub.get('uid', uid, `${nconf.get('url')}/category/${cid}`), + { message: '[[error:activitypub.get-failed]]' } + ); + }); + + it('should not respond to a request for a topic collection', async () => { + await assert.rejects( + activitypub.get('uid', uid, `${nconf.get('url')}/topic/${topicData.tid}`), + { message: '[[error:activitypub.get-failed]]' } + ); + }); + + it('should not respond to a request for a post', async () => { + await assert.rejects( + activitypub.get('uid', uid, `${nconf.get('url')}/post/${topicData.mainPid}`), + { message: '[[error:activitypub.get-failed]]' } + ); + }); + }); + + describe('incoming activities', () => { + describe('Create(Note)', () => { + let note; + let activity; + + before(async () => { + ({ note } = helpers.mocks.note({ + cc: [`${nconf.get('url')}/category/${cid}`], + })); + ({ activity } = helpers.mocks.create(note)); + await activitypub.inbox.create({ body: activity }); + }); + + it('should not assert the note', async () => { + const exists = await posts.exists(note.id); + assert.strictEqual(exists, false); + }); + }); + + describe('Update(Note)', () => { + let note; + let activity; + + before(async () => { + ({ note } = helpers.mocks.note({ + cc: [`${nconf.get('url')}/category/${cid}`], + })); + ({ activity } = helpers.mocks.create(note)); + await privileges.categories.give(['groups:topics:create'], cid, ['fediverse']); + await activitypub.inbox.create({ body: activity }); + }); + + after(async () => { + await privileges.categories.rescind(['groups:topics:create'], cid, ['fediverse']); + }); + + it('should assert the note', async () => { + const exists = await posts.exists(note.id); + assert.strictEqual(exists, true); + }); + + it('should not allow edits to the note', async () => { + const oldContent = note.content; + note.content = 'new content'; + ({ activity } = helpers.mocks.update({ + object: note, + })); + + await activitypub.inbox.update({ body: activity }); + + const postData = await posts.getPostData(note.id); + assert.strictEqual(postData.content, oldContent); + assert.strictEqual(postData.edited, 0); + }); + }); + + describe('Delete(Note)', () => { + let note; + let activity; + + before(async () => { + ({ note } = helpers.mocks.note({ + cc: [`${nconf.get('url')}/category/${cid}`], + })); + ({ activity } = helpers.mocks.create(note)); + await privileges.categories.give(['groups:topics:create'], cid, ['fediverse']); + await activitypub.inbox.create({ body: activity }); + }); + + after(async () => { + await privileges.categories.rescind(['groups:topics:create'], cid, ['fediverse']); + }); + + it('should assert the note', async () => { + const exists = await posts.exists(note.id); + assert.strictEqual(exists, true); + }); + + it('should ignore remote deletion of said note', async () => { + ({ activity } = helpers.mocks.delete({ object: note })); + await activitypub.inbox.delete({ body: activity }); + + const exists = await posts.exists(note.id); + assert.strictEqual(exists, true); + }); + }); + }); + + describe('outgoing requests', () => { + it('should not federate out a new post', async () => { + + }); + + it('should not federate out a post edit', async () => { + + }); + + it('should not federate out a post deletion', async () => { + + }); + + it('should not federate out a post announce', async () => { + + }); + }); + }); + + describe('regular privilege set', () => { + let cid; + let handle; + + before(async () => { + ({ cid } = await categories.create({ name: utils.generateUUID() })); + handle = await categories.getCategoryField(cid, 'handle'); + const privsToRemove = await privileges.categories.getGroupPrivilegeList(); + }); + + describe('groups:find', () => { + it('should return webfinger response to a category\'s handle', async () => { + const { response, body } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct:${handle}@${nconf.get('url_parsed').host}`); + + assert(response); + assert.strictEqual(response.statusCode, 200); + assert(body.links && body.links.length); + assert.strictEqual(body.subject, `acct:${handle}@${nconf.get('url_parsed').host}`); + }); + }); + }); + }); +}); \ No newline at end of file