nodebb-plugin-link-preview 1.3.2 → 2.0.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.
Files changed (3) hide show
  1. package/library.js +95 -71
  2. package/package.json +4 -3
  3. package/plugin.json +3 -1
package/library.js CHANGED
@@ -9,8 +9,8 @@ const dns = require('dns');
9
9
 
10
10
  const { getLinkPreview } = require('link-preview-js');
11
11
  const { load } = require('cheerio');
12
+ const { isURL } = require('validator');
12
13
 
13
- const db = require.main.require('./src/database');
14
14
  const meta = require.main.require('./src/meta');
15
15
  const cache = require.main.require('./src/cache');
16
16
  const posts = require.main.require('./src/posts');
@@ -82,64 +82,24 @@ async function preview(url) {
82
82
  });
83
83
  }
84
84
 
85
- async function processAttachments({ content, pid, tid }) {
86
- // Retrieve attachments
87
- const hashes = await db.getSortedSetMembers(`post:${pid}:attachments`);
88
- if (!hashes.length) {
85
+ async function process(content, opts) {
86
+ const { embedHtml, embedImage, embedAudio, embedVideo } = await meta.settings.get('link-preview');
87
+ if (![embedHtml, embedImage, embedAudio, embedVideo].some(prop => prop === 'on')) {
89
88
  return content;
90
89
  }
91
90
 
92
- const keys = hashes.map(hash => `attachment:${hash}`);
93
- const attachments = (await db.getObjects(keys)).filter(Boolean);
94
- const urls = attachments
95
- .filter(attachment => cache.has(`link-preview:${attachment.url}`))
96
- .map(attachment => attachment.url);
97
-
98
- const previews = urls.map(url => cache.get(`link-preview:${url}`));
99
- const html = await Promise.all(previews.map(async preview => await render(preview)));
100
-
101
- // Append all readily-available previews to content
102
- content = `${content}\n\n<div class="row">${html.map(html => `<div class="col-6">${html}</div>`).join('\n')}</div>`;
91
+ const requests = new Map();
103
92
 
104
- // Kickstart preview
105
- const toFetch = attachments.filter(attachment => !cache.has(`link-preview:${attachment.url}`));
106
- if (toFetch.length) {
107
- Promise.all(toFetch.map(async attachment => preview(attachment.url))).then(async () => {
108
- // bust posts cache item
109
- if (await posts.exists(pid)) {
110
- postsCache.del(String(pid));
111
-
112
- // fire post edit event with mocked data
113
- if (await topics.exists(tid)) {
114
- const urls = attachments.map(attachment => attachment.url);
115
-
116
- const previews = urls.map(url => cache.get(`link-preview:${url}`));
117
- let html = await Promise.all(previews.map(async preview => await render(preview)));
118
- html = `${content}\n\n<div class="row">${html.map(html => `<div class="col-6">${html}</div>`).join('\n')}</div>`;
119
-
120
- websockets.in(`topic_${tid}`).emit('event:post_edited', {
121
- post: {
122
- tid,
123
- pid,
124
- changed: true,
125
- content: html,
126
- },
127
- topic: {},
128
- });
129
- }
130
- }
93
+ // Retrieve attachments
94
+ if (opts.hasOwnProperty('pid') && await posts.exists(opts.pid)) {
95
+ const attachments = await posts.attachments.get(opts.pid);
96
+ attachments.forEach(({ url, _type }) => {
97
+ const type = _type || 'attachment';
98
+ requests.set(url, { type });
131
99
  });
132
100
  }
133
101
 
134
- return content;
135
- }
136
-
137
- async function process(content, opts) {
138
- const { embedHtml, embedImage, embedAudio, embedVideo } = await meta.settings.get('link-preview');
139
- if (![embedHtml, embedImage, embedAudio, embedVideo].some(prop => prop === 'on')) {
140
- return content;
141
- }
142
-
102
+ // Parse inline urls
143
103
  const $ = load(content, null, false);
144
104
  for (const anchor of $('a')) {
145
105
  const $anchor = $(anchor);
@@ -162,28 +122,73 @@ async function process(content, opts) {
162
122
  continue;
163
123
  }
164
124
 
125
+ // Inline url takes precedence over attachment
126
+ requests.set(url, {
127
+ type: 'inline',
128
+ target: $anchor,
129
+ });
130
+ }
131
+
132
+ // Render cache hits immediately
133
+ let attachmentHtml = '';
134
+ const cold = new Set();
135
+ await Promise.all(Array.from(requests.keys()).map(async (url) => {
136
+ const options = requests.get(url);
165
137
  const cached = cache.get(`link-preview:${url}`);
166
138
  if (cached) {
167
139
  const html = await render(cached);
168
140
  if (html) {
169
- $anchor.replaceWith($(html));
141
+ switch (options.type) {
142
+ case 'inline': {
143
+ const $anchor = options.target;
144
+ $anchor.replaceWith($(html));
145
+ break;
146
+ }
147
+
148
+ case 'attachment': {
149
+ attachmentHtml += `<div class="col-6">${html}</div>`;
150
+ break;
151
+ }
152
+ }
170
153
  }
171
- continue;
154
+ } else {
155
+ cold.add(url);
172
156
  }
157
+ }));
158
+
159
+ // Start preview for cache misses, but continue for now so as to not block response
160
+ if (cold.size) {
161
+ const coldArr = Array.from(cold);
162
+ Promise.all(coldArr.map(preview)).then(async (previews) => {
163
+ await Promise.all(previews.map(async (preview, idx) => {
164
+ if (!preview) {
165
+ return;
166
+ }
173
167
 
174
- // Generate the preview, but continue for now so as to not block response
175
- preview(url).then(async (preview) => {
176
- if (!preview) {
177
- return;
178
- }
179
-
180
- const parsedUrl = new URL(url);
181
- preview.hostname = parsedUrl.hostname;
168
+ const url = coldArr[idx];
169
+ const options = requests.get(url);
170
+ const parsedUrl = new URL(url);
171
+ preview.hostname = parsedUrl.hostname;
172
+
173
+ const html = await render(preview);
174
+ if (html) {
175
+ switch (options.type) {
176
+ case 'inline': {
177
+ const $anchor = options.target;
178
+ $anchor.replaceWith($(html));
179
+ break;
180
+ }
181
+
182
+ case 'attachment': {
183
+ attachmentHtml += `<div class="col-6">${html}</div>`;
184
+ break;
185
+ }
186
+ }
187
+ }
188
+ }));
182
189
 
183
- const html = await render(preview);
184
- if (!html) {
185
- return;
186
- }
190
+ let content = $.html();
191
+ content += attachmentHtml ? `\n\n<div class="row">${attachmentHtml}</div>` : '';
187
192
 
188
193
  // bust posts cache item
189
194
  if (opts.hasOwnProperty('pid') && await posts.exists(opts.pid)) {
@@ -191,13 +196,12 @@ async function process(content, opts) {
191
196
 
192
197
  // fire post edit event with mocked data
193
198
  if (opts.hasOwnProperty('tid') && await topics.exists(opts.tid)) {
194
- $anchor.replaceWith($(html));
195
199
  websockets.in(`topic_${opts.tid}`).emit('event:post_edited', {
196
200
  post: {
197
201
  tid: opts.tid,
198
202
  pid: opts.pid,
199
203
  changed: true,
200
- content: $.html(),
204
+ content,
201
205
  },
202
206
  topic: {},
203
207
  });
@@ -206,7 +210,9 @@ async function process(content, opts) {
206
210
  });
207
211
  }
208
212
 
209
- return $.html();
213
+ content = $.html();
214
+ content += attachmentHtml ? `\n\n<div class="row">${attachmentHtml}</div>` : '';
215
+ return content;
210
216
  }
211
217
 
212
218
  async function render(preview) {
@@ -285,14 +291,32 @@ plugin.onParse = async (payload) => {
285
291
  if (typeof payload === 'string') { // raw
286
292
  payload = await process(payload, {});
287
293
  } else if (payload && payload.postData && payload.postData.content) { // post
288
- let { content, pid, tid } = payload.postData;
289
- content = await processAttachments({ content, pid, tid });
294
+ const { content, pid, tid } = payload.postData;
290
295
  payload.postData.content = await process(content, { pid, tid });
291
296
  }
292
297
 
293
298
  return payload;
294
299
  };
295
300
 
301
+ plugin.onPost = async ({ post }) => {
302
+ if (post._activitypub) {
303
+ return; // no attachment parsing for content from activitypub; attachments saved via notes.assert
304
+ }
305
+
306
+ // Only match standalone URLs on their own line
307
+ const lines = post.content.split('\n');
308
+ const urls = lines.filter(line => isURL(line));
309
+
310
+ let previews = await Promise.all(urls.map(async url => await preview(url)));
311
+ previews = previews.map(({ url, contentType: mediaType }) => ({
312
+ type: 'inline',
313
+ url,
314
+ mediaType,
315
+ })).filter(Boolean);
316
+
317
+ posts.attachments.update(post.pid, previews);
318
+ };
319
+
296
320
  plugin.addAdminNavigation = (header) => {
297
321
  header.plugins.push({
298
322
  route: '/plugins/link-preview',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-link-preview",
3
- "version": "1.3.2",
3
+ "version": "2.0.0",
4
4
  "description": "A starter kit for quickly creating NodeBB plugins",
5
5
  "main": "library.js",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "readmeFilename": "README.md",
33
33
  "nbbpm": {
34
- "compatibility": "^3.2.0"
34
+ "compatibility": "^4.0.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@commitlint/cli": "17.6.5",
@@ -44,6 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "cheerio": "^1.0.0-rc.12",
47
- "link-preview-js": "^3.0.4"
47
+ "link-preview-js": "^3.0.4",
48
+ "validator": "^13.11.0"
48
49
  }
49
50
  }
package/plugin.json CHANGED
@@ -7,7 +7,9 @@
7
7
  { "hook": "filter:admin.header.build", "method": "addAdminNavigation" },
8
8
  { "hook": "filter:settings.get", "method": "applyDefaults" },
9
9
  { "hook": "filter:parse.post", "method": "onParse" },
10
- { "hook": "filter:parse.raw", "method": "onParse" }
10
+ { "hook": "filter:parse.raw", "method": "onParse" },
11
+ { "hook": "action:post.save", "method": "onPost" },
12
+ { "hook": "action:post.edit", "method": "onPost" }
11
13
  ],
12
14
  "scss": [
13
15
  "static/scss/link-preview.scss"