aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--server/helpers/requests.ts18
-rw-r--r--server/lib/activitypub/actor.ts22
-rw-r--r--server/lib/activitypub/playlist.ts17
-rw-r--r--server/lib/activitypub/videos.ts19
-rw-r--r--server/lib/job-queue/handlers/activitypub-cleaner.ts49
-rw-r--r--server/tests/api/activitypub/security.ts73
-rw-r--r--server/tools/peertube-import-videos.ts5
7 files changed, 118 insertions, 85 deletions
diff --git a/server/helpers/requests.ts b/server/helpers/requests.ts
index aee8f6673..5eb69486d 100644
--- a/server/helpers/requests.ts
+++ b/server/helpers/requests.ts
@@ -1,5 +1,5 @@
1import { createWriteStream, remove } from 'fs-extra' 1import { createWriteStream, remove } from 'fs-extra'
2import got, { CancelableRequest, Options as GotOptions } from 'got' 2import got, { CancelableRequest, Options as GotOptions, RequestError } from 'got'
3import { join } from 'path' 3import { join } from 'path'
4import { CONFIG } from '../initializers/config' 4import { CONFIG } from '../initializers/config'
5import { ACTIVITY_PUB, PEERTUBE_VERSION, WEBSERVER } from '../initializers/constants' 5import { ACTIVITY_PUB, PEERTUBE_VERSION, WEBSERVER } from '../initializers/constants'
@@ -7,6 +7,11 @@ import { pipelinePromise } from './core-utils'
7import { processImage } from './image-utils' 7import { processImage } from './image-utils'
8import { logger } from './logger' 8import { logger } from './logger'
9 9
10export interface PeerTubeRequestError extends Error {
11 statusCode?: number
12 responseBody?: any
13}
14
10const httpSignature = require('http-signature') 15const httpSignature = require('http-signature')
11 16
12type PeerTubeRequestOptions = { 17type PeerTubeRequestOptions = {
@@ -180,14 +185,15 @@ function buildGotOptions (options: PeerTubeRequestOptions) {
180 } 185 }
181} 186}
182 187
183function buildRequestError (error: any) { 188function buildRequestError (error: RequestError) {
184 const newError = new Error(error.message) 189 const newError: PeerTubeRequestError = new Error(error.message)
185 newError.name = error.name 190 newError.name = error.name
186 newError.stack = error.stack 191 newError.stack = error.stack
187 192
188 if (error.response?.body) { 193 if (error.response) {
189 error.responseBody = error.response.body 194 newError.responseBody = error.response.body
195 newError.statusCode = error.response.statusCode
190 } 196 }
191 197
192 return error 198 return newError
193} 199}
diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts
index 52b6c1f56..3c9a7ba02 100644
--- a/server/lib/activitypub/actor.ts
+++ b/server/lib/activitypub/actor.ts
@@ -14,7 +14,7 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
14import { retryTransactionWrapper, updateInstanceWithAnother } from '../../helpers/database-utils' 14import { retryTransactionWrapper, updateInstanceWithAnother } from '../../helpers/database-utils'
15import { logger } from '../../helpers/logger' 15import { logger } from '../../helpers/logger'
16import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto' 16import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto'
17import { doJSONRequest } from '../../helpers/requests' 17import { doJSONRequest, PeerTubeRequestError } from '../../helpers/requests'
18import { getUrlFromWebfinger } from '../../helpers/webfinger' 18import { getUrlFromWebfinger } from '../../helpers/webfinger'
19import { MIMETYPES, WEBSERVER } from '../../initializers/constants' 19import { MIMETYPES, WEBSERVER } from '../../initializers/constants'
20import { sequelizeTypescript } from '../../initializers/database' 20import { sequelizeTypescript } from '../../initializers/database'
@@ -279,16 +279,7 @@ async function refreshActorIfNeeded <T extends MActorFull | MActorAccountChannel
279 actorUrl = actor.url 279 actorUrl = actor.url
280 } 280 }
281 281
282 const { result, statusCode } = await fetchRemoteActor(actorUrl) 282 const { result } = await fetchRemoteActor(actorUrl)
283
284 if (statusCode === HttpStatusCode.NOT_FOUND_404) {
285 logger.info('Deleting actor %s because there is a 404 in refresh actor.', actor.url)
286 actor.Account
287 ? await actor.Account.destroy()
288 : await actor.VideoChannel.destroy()
289
290 return { actor: undefined, refreshed: false }
291 }
292 283
293 if (result === undefined) { 284 if (result === undefined) {
294 logger.warn('Cannot fetch remote actor in refresh actor.') 285 logger.warn('Cannot fetch remote actor in refresh actor.')
@@ -328,6 +319,15 @@ async function refreshActorIfNeeded <T extends MActorFull | MActorAccountChannel
328 return { refreshed: true, actor } 319 return { refreshed: true, actor }
329 }) 320 })
330 } catch (err) { 321 } catch (err) {
322 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
323 logger.info('Deleting actor %s because there is a 404 in refresh actor.', actor.url)
324 actor.Account
325 ? await actor.Account.destroy()
326 : await actor.VideoChannel.destroy()
327
328 return { actor: undefined, refreshed: false }
329 }
330
331 logger.warn('Cannot refresh actor %s.', actor.url, { err }) 331 logger.warn('Cannot refresh actor %s.', actor.url, { err })
332 return { actor, refreshed: false } 332 return { actor, refreshed: false }
333 } 333 }
diff --git a/server/lib/activitypub/playlist.ts b/server/lib/activitypub/playlist.ts
index 795be60d7..7166c68a6 100644
--- a/server/lib/activitypub/playlist.ts
+++ b/server/lib/activitypub/playlist.ts
@@ -7,7 +7,7 @@ import { checkUrlsSameHost } from '../../helpers/activitypub'
7import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '../../helpers/custom-validators/activitypub/playlist' 7import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '../../helpers/custom-validators/activitypub/playlist'
8import { isArray } from '../../helpers/custom-validators/misc' 8import { isArray } from '../../helpers/custom-validators/misc'
9import { logger } from '../../helpers/logger' 9import { logger } from '../../helpers/logger'
10import { doJSONRequest } from '../../helpers/requests' 10import { doJSONRequest, PeerTubeRequestError } from '../../helpers/requests'
11import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants' 11import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants'
12import { sequelizeTypescript } from '../../initializers/database' 12import { sequelizeTypescript } from '../../initializers/database'
13import { VideoPlaylistModel } from '../../models/video/video-playlist' 13import { VideoPlaylistModel } from '../../models/video/video-playlist'
@@ -116,13 +116,7 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner)
116 if (!videoPlaylist.isOutdated()) return videoPlaylist 116 if (!videoPlaylist.isOutdated()) return videoPlaylist
117 117
118 try { 118 try {
119 const { statusCode, playlistObject } = await fetchRemoteVideoPlaylist(videoPlaylist.url) 119 const { playlistObject } = await fetchRemoteVideoPlaylist(videoPlaylist.url)
120 if (statusCode === HttpStatusCode.NOT_FOUND_404) {
121 logger.info('Cannot refresh remote video playlist %s: it does not exist anymore. Deleting it.', videoPlaylist.url)
122
123 await videoPlaylist.destroy()
124 return undefined
125 }
126 120
127 if (playlistObject === undefined) { 121 if (playlistObject === undefined) {
128 logger.warn('Cannot refresh remote playlist %s: invalid body.', videoPlaylist.url) 122 logger.warn('Cannot refresh remote playlist %s: invalid body.', videoPlaylist.url)
@@ -136,6 +130,13 @@ async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner)
136 130
137 return videoPlaylist 131 return videoPlaylist
138 } catch (err) { 132 } catch (err) {
133 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
134 logger.info('Cannot refresh remote video playlist %s: it does not exist anymore. Deleting it.', videoPlaylist.url)
135
136 await videoPlaylist.destroy()
137 return undefined
138 }
139
139 logger.warn('Cannot refresh video playlist %s.', videoPlaylist.url, { err }) 140 logger.warn('Cannot refresh video playlist %s.', videoPlaylist.url, { err })
140 141
141 await videoPlaylist.setAsRefreshed() 142 await videoPlaylist.setAsRefreshed()
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index a5f58dd01..d484edd36 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -30,7 +30,7 @@ import { isArray } from '../../helpers/custom-validators/misc'
30import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos' 30import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
31import { deleteNonExistingModels, resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils' 31import { deleteNonExistingModels, resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
32import { logger } from '../../helpers/logger' 32import { logger } from '../../helpers/logger'
33import { doJSONRequest } from '../../helpers/requests' 33import { doJSONRequest, PeerTubeRequestError } from '../../helpers/requests'
34import { fetchVideoByUrl, getExtFromMimetype, VideoFetchByUrlType } from '../../helpers/video' 34import { fetchVideoByUrl, getExtFromMimetype, VideoFetchByUrlType } from '../../helpers/video'
35import { 35import {
36 ACTIVITY_PUB, 36 ACTIVITY_PUB,
@@ -523,14 +523,7 @@ async function refreshVideoIfNeeded (options: {
523 : await VideoModel.loadByUrlAndPopulateAccount(options.video.url) 523 : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
524 524
525 try { 525 try {
526 const { statusCode, videoObject } = await fetchRemoteVideo(video.url) 526 const { videoObject } = await fetchRemoteVideo(video.url)
527 if (statusCode === HttpStatusCode.NOT_FOUND_404) {
528 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
529
530 // Video does not exist anymore
531 await video.destroy()
532 return undefined
533 }
534 527
535 if (videoObject === undefined) { 528 if (videoObject === undefined) {
536 logger.warn('Cannot refresh remote video %s: invalid body.', video.url) 529 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
@@ -554,6 +547,14 @@ async function refreshVideoIfNeeded (options: {
554 547
555 return video 548 return video
556 } catch (err) { 549 } catch (err) {
550 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
551 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
552
553 // Video does not exist anymore
554 await video.destroy()
555 return undefined
556 }
557
557 logger.warn('Cannot refresh video %s.', options.video.url, { err }) 558 logger.warn('Cannot refresh video %s.', options.video.url, { err })
558 559
559 ActorFollowScoreCache.Instance.addBadServerId(video.VideoChannel.Actor.serverId) 560 ActorFollowScoreCache.Instance.addBadServerId(video.VideoChannel.Actor.serverId)
diff --git a/server/lib/job-queue/handlers/activitypub-cleaner.ts b/server/lib/job-queue/handlers/activitypub-cleaner.ts
index 9dcc778fa..1caca1dcc 100644
--- a/server/lib/job-queue/handlers/activitypub-cleaner.ts
+++ b/server/lib/job-queue/handlers/activitypub-cleaner.ts
@@ -7,7 +7,7 @@ import {
7 isLikeActivityValid 7 isLikeActivityValid
8} from '@server/helpers/custom-validators/activitypub/activity' 8} from '@server/helpers/custom-validators/activitypub/activity'
9import { sanitizeAndCheckVideoCommentObject } from '@server/helpers/custom-validators/activitypub/video-comments' 9import { sanitizeAndCheckVideoCommentObject } from '@server/helpers/custom-validators/activitypub/video-comments'
10import { doJSONRequest } from '@server/helpers/requests' 10import { doJSONRequest, PeerTubeRequestError } from '@server/helpers/requests'
11import { AP_CLEANER_CONCURRENCY } from '@server/initializers/constants' 11import { AP_CLEANER_CONCURRENCY } from '@server/initializers/constants'
12import { VideoModel } from '@server/models/video/video' 12import { VideoModel } from '@server/models/video/video'
13import { VideoCommentModel } from '@server/models/video/video-comment' 13import { VideoCommentModel } from '@server/models/video/video-comment'
@@ -81,39 +81,44 @@ async function updateObjectIfNeeded <T> (
81 updater: (url: string, newUrl: string) => Promise<T>, 81 updater: (url: string, newUrl: string) => Promise<T>,
82 deleter: (url: string) => Promise<T> 82 deleter: (url: string) => Promise<T>
83): Promise<{ data: T, status: 'deleted' | 'updated' } | null> { 83): Promise<{ data: T, status: 'deleted' | 'updated' } | null> {
84 const { statusCode, body } = await doJSONRequest<any>(url, { activityPub: true }) 84 const on404OrTombstone = async () => {
85
86 // Does not exist anymore, remove entry
87 if (statusCode === HttpStatusCode.NOT_FOUND_404) {
88 logger.info('Removing remote AP object %s.', url) 85 logger.info('Removing remote AP object %s.', url)
89 const data = await deleter(url) 86 const data = await deleter(url)
90 87
91 return { status: 'deleted', data } 88 return { status: 'deleted' as 'deleted', data }
92 } 89 }
93 90
94 // If not same id, check same host and update 91 try {
95 if (!body || !body.id || !bodyValidator(body)) throw new Error(`Body or body id of ${url} is invalid`) 92 const { body } = await doJSONRequest<any>(url, { activityPub: true })
96 93
97 if (body.type === 'Tombstone') { 94 // If not same id, check same host and update
98 logger.info('Removing remote AP object %s.', url) 95 if (!body || !body.id || !bodyValidator(body)) throw new Error(`Body or body id of ${url} is invalid`)
99 const data = await deleter(url)
100 96
101 return { status: 'deleted', data } 97 if (body.type === 'Tombstone') {
102 } 98 return on404OrTombstone()
99 }
103 100
104 const newUrl = body.id 101 const newUrl = body.id
105 if (newUrl !== url) { 102 if (newUrl !== url) {
106 if (checkUrlsSameHost(newUrl, url) !== true) { 103 if (checkUrlsSameHost(newUrl, url) !== true) {
107 throw new Error(`New url ${newUrl} has not the same host than old url ${url}`) 104 throw new Error(`New url ${newUrl} has not the same host than old url ${url}`)
105 }
106
107 logger.info('Updating remote AP object %s.', url)
108 const data = await updater(url, newUrl)
109
110 return { status: 'updated', data }
108 } 111 }
109 112
110 logger.info('Updating remote AP object %s.', url) 113 return null
111 const data = await updater(url, newUrl) 114 } catch (err) {
115 // Does not exist anymore, remove entry
116 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
117 return on404OrTombstone()
118 }
112 119
113 return { status: 'updated', data } 120 throw err
114 } 121 }
115
116 return null
117} 122}
118 123
119function rateOptionsFactory () { 124function rateOptionsFactory () {
diff --git a/server/tests/api/activitypub/security.ts b/server/tests/api/activitypub/security.ts
index 26b4545ac..9745052a3 100644
--- a/server/tests/api/activitypub/security.ts
+++ b/server/tests/api/activitypub/security.ts
@@ -79,9 +79,12 @@ describe('Test ActivityPub security', function () {
79 Digest: buildDigest({ hello: 'coucou' }) 79 Digest: buildDigest({ hello: 'coucou' })
80 } 80 }
81 81
82 const { statusCode } = await makePOSTAPRequest(url, body, baseHttpSignature(), headers) 82 try {
83 83 await makePOSTAPRequest(url, body, baseHttpSignature(), headers)
84 expect(statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) 84 expect(true, 'Did not throw').to.be.false
85 } catch (err) {
86 expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403)
87 }
85 }) 88 })
86 89
87 it('Should fail with an invalid date', async function () { 90 it('Should fail with an invalid date', async function () {
@@ -89,9 +92,12 @@ describe('Test ActivityPub security', function () {
89 const headers = buildGlobalHeaders(body) 92 const headers = buildGlobalHeaders(body)
90 headers['date'] = 'Wed, 21 Oct 2015 07:28:00 GMT' 93 headers['date'] = 'Wed, 21 Oct 2015 07:28:00 GMT'
91 94
92 const { statusCode } = await makePOSTAPRequest(url, body, baseHttpSignature(), headers) 95 try {
93 96 await makePOSTAPRequest(url, body, baseHttpSignature(), headers)
94 expect(statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) 97 expect(true, 'Did not throw').to.be.false
98 } catch (err) {
99 expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403)
100 }
95 }) 101 })
96 102
97 it('Should fail with bad keys', async function () { 103 it('Should fail with bad keys', async function () {
@@ -101,9 +107,12 @@ describe('Test ActivityPub security', function () {
101 const body = activityPubContextify(getAnnounceWithoutContext(servers[1])) 107 const body = activityPubContextify(getAnnounceWithoutContext(servers[1]))
102 const headers = buildGlobalHeaders(body) 108 const headers = buildGlobalHeaders(body)
103 109
104 const { statusCode } = await makePOSTAPRequest(url, body, baseHttpSignature(), headers) 110 try {
105 111 await makePOSTAPRequest(url, body, baseHttpSignature(), headers)
106 expect(statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) 112 expect(true, 'Did not throw').to.be.false
113 } catch (err) {
114 expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403)
115 }
107 }) 116 })
108 117
109 it('Should reject requests without appropriate signed headers', async function () { 118 it('Should reject requests without appropriate signed headers', async function () {
@@ -123,8 +132,12 @@ describe('Test ActivityPub security', function () {
123 for (const badHeaders of badHeadersMatrix) { 132 for (const badHeaders of badHeadersMatrix) {
124 signatureOptions.headers = badHeaders 133 signatureOptions.headers = badHeaders
125 134
126 const { statusCode } = await makePOSTAPRequest(url, body, signatureOptions, headers) 135 try {
127 expect(statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) 136 await makePOSTAPRequest(url, body, signatureOptions, headers)
137 expect(true, 'Did not throw').to.be.false
138 } catch (err) {
139 expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403)
140 }
128 } 141 }
129 }) 142 })
130 143
@@ -133,7 +146,6 @@ describe('Test ActivityPub security', function () {
133 const headers = buildGlobalHeaders(body) 146 const headers = buildGlobalHeaders(body)
134 147
135 const { statusCode } = await makePOSTAPRequest(url, body, baseHttpSignature(), headers) 148 const { statusCode } = await makePOSTAPRequest(url, body, baseHttpSignature(), headers)
136
137 expect(statusCode).to.equal(HttpStatusCode.NO_CONTENT_204) 149 expect(statusCode).to.equal(HttpStatusCode.NO_CONTENT_204)
138 }) 150 })
139 151
@@ -150,9 +162,12 @@ describe('Test ActivityPub security', function () {
150 const body = activityPubContextify(getAnnounceWithoutContext(servers[1])) 162 const body = activityPubContextify(getAnnounceWithoutContext(servers[1]))
151 const headers = buildGlobalHeaders(body) 163 const headers = buildGlobalHeaders(body)
152 164
153 const { statusCode } = await makePOSTAPRequest(url, body, baseHttpSignature(), headers) 165 try {
154 166 await makePOSTAPRequest(url, body, baseHttpSignature(), headers)
155 expect(statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) 167 expect(true, 'Did not throw').to.be.false
168 } catch (err) {
169 expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403)
170 }
156 }) 171 })
157 }) 172 })
158 173
@@ -183,9 +198,12 @@ describe('Test ActivityPub security', function () {
183 198
184 const headers = buildGlobalHeaders(signedBody) 199 const headers = buildGlobalHeaders(signedBody)
185 200
186 const { statusCode } = await makePOSTAPRequest(url, signedBody, baseHttpSignature(), headers) 201 try {
187 202 await makePOSTAPRequest(url, signedBody, baseHttpSignature(), headers)
188 expect(statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) 203 expect(true, 'Did not throw').to.be.false
204 } catch (err) {
205 expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403)
206 }
189 }) 207 })
190 208
191 it('Should fail with an altered body', async function () { 209 it('Should fail with an altered body', async function () {
@@ -204,9 +222,12 @@ describe('Test ActivityPub security', function () {
204 222
205 const headers = buildGlobalHeaders(signedBody) 223 const headers = buildGlobalHeaders(signedBody)
206 224
207 const { statusCode } = await makePOSTAPRequest(url, signedBody, baseHttpSignature(), headers) 225 try {
208 226 await makePOSTAPRequest(url, signedBody, baseHttpSignature(), headers)
209 expect(statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) 227 expect(true, 'Did not throw').to.be.false
228 } catch (err) {
229 expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403)
230 }
210 }) 231 })
211 232
212 it('Should succeed with a valid signature', async function () { 233 it('Should succeed with a valid signature', async function () {
@@ -221,7 +242,6 @@ describe('Test ActivityPub security', function () {
221 const headers = buildGlobalHeaders(signedBody) 242 const headers = buildGlobalHeaders(signedBody)
222 243
223 const { statusCode } = await makePOSTAPRequest(url, signedBody, baseHttpSignature(), headers) 244 const { statusCode } = await makePOSTAPRequest(url, signedBody, baseHttpSignature(), headers)
224
225 expect(statusCode).to.equal(HttpStatusCode.NO_CONTENT_204) 245 expect(statusCode).to.equal(HttpStatusCode.NO_CONTENT_204)
226 }) 246 })
227 247
@@ -243,9 +263,12 @@ describe('Test ActivityPub security', function () {
243 263
244 const headers = buildGlobalHeaders(signedBody) 264 const headers = buildGlobalHeaders(signedBody)
245 265
246 const { statusCode } = await makePOSTAPRequest(url, signedBody, baseHttpSignature(), headers) 266 try {
247 267 await makePOSTAPRequest(url, signedBody, baseHttpSignature(), headers)
248 expect(statusCode).to.equal(HttpStatusCode.FORBIDDEN_403) 268 expect(true, 'Did not throw').to.be.false
269 } catch (err) {
270 expect(err.statusCode).to.equal(HttpStatusCode.FORBIDDEN_403)
271 }
249 }) 272 })
250 }) 273 })
251 274
diff --git a/server/tools/peertube-import-videos.ts b/server/tools/peertube-import-videos.ts
index 9be0834ba..915995031 100644
--- a/server/tools/peertube-import-videos.ts
+++ b/server/tools/peertube-import-videos.ts
@@ -202,10 +202,7 @@ async function uploadVideoOnPeerTube (parameters: {
202 if (videoInfo.thumbnail) { 202 if (videoInfo.thumbnail) {
203 thumbnailfile = join(cwd, sha256(videoInfo.thumbnail) + '.jpg') 203 thumbnailfile = join(cwd, sha256(videoInfo.thumbnail) + '.jpg')
204 204
205 await doRequestAndSaveToFile({ 205 await doRequestAndSaveToFile(videoInfo.thumbnail, thumbnailfile)
206 method: 'GET',
207 uri: videoInfo.thumbnail
208 }, thumbnailfile)
209 } 206 }
210 207
211 const originallyPublishedAt = buildOriginallyPublishedAt(videoInfo) 208 const originallyPublishedAt = buildOriginallyPublishedAt(videoInfo)