浏览代码

@uppy/companion: implement facebook app secret proof (#5249)

note that I couldn't get `appsecret_time` working, but it seems to be working without
Mikael Finstad 10 月之前
父节点
当前提交
3310c12db5

+ 1 - 0
docs/guides/migration-guides.md

@@ -32,6 +32,7 @@ These cover all the major Uppy versions and how to migrate to them.
   - `getProtectedHttpAgent` parameter `blockLocalIPs` changed to `allowLocalIPs`
     (inverted boolean).
   - `downloadURL` 2nd (boolean) argument inverted.
+  - `StreamHttpJsonError` renamed to `HttpError`.
 
 ### `@uppy/companion-client`
 

+ 5 - 5
packages/@uppy/companion/src/server/helpers/utils.js

@@ -148,7 +148,7 @@ module.exports.decrypt = (encrypted, secret) => {
 
 module.exports.defaultGetKey = ({ filename }) => `${crypto.randomUUID()}-${filename}`
 
-class StreamHttpJsonError extends Error {
+class HttpError extends Error {
   statusCode
 
   responseJson
@@ -157,11 +157,11 @@ class StreamHttpJsonError extends Error {
     super(`Request failed with status ${statusCode}`)
     this.statusCode = statusCode
     this.responseJson = responseJson
-    this.name = 'StreamHttpJsonError'
+    this.name = 'HttpError'
   }
 }
 
-module.exports.StreamHttpJsonError = StreamHttpJsonError
+module.exports.HttpError = HttpError
 
 module.exports.prepareStream = async (stream) => new Promise((resolve, reject) => {
   stream
@@ -176,7 +176,7 @@ module.exports.prepareStream = async (stream) => new Promise((resolve, reject) =
     })
     .on('error', (err) => {
       // In this case the error object is not a normal GOT HTTPError where json is already parsed,
-      // we create our own StreamHttpJsonError error for this case
+      // we create our own HttpError error for this case
       if (typeof err.response?.body === 'string' && typeof err.response?.statusCode === 'number') {
         let responseJson
         try {
@@ -186,7 +186,7 @@ module.exports.prepareStream = async (stream) => new Promise((resolve, reject) =
           return
         }
 
-        reject(new StreamHttpJsonError({ statusCode: err.response.statusCode, responseJson }))
+        reject(new HttpError({ statusCode: err.response.statusCode, responseJson }))
         return
       }
 

+ 3 - 2
packages/@uppy/companion/src/server/provider/Provider.js

@@ -6,13 +6,14 @@ const { MAX_AGE_24H } = require('../helpers/jwt')
 class Provider {
   /**
    *
-   * @param {{providerName: string, allowLocalUrls: boolean, providerGrantConfig?: object}} options
+   * @param {{providerName: string, allowLocalUrls: boolean, providerGrantConfig?: object, secret: string}} options
    */
-  constructor ({ allowLocalUrls, providerGrantConfig }) {
+  constructor ({ allowLocalUrls, providerGrantConfig, secret }) {
     // Some providers might need cookie auth for the thumbnails fetched via companion
     this.needsCookieAuth = false
     this.allowLocalUrls = allowLocalUrls
     this.providerGrantConfig = providerGrantConfig
+    this.secret = secret
     return this
   }
 

+ 62 - 17
packages/@uppy/companion/src/server/provider/facebook/index.js

@@ -1,24 +1,57 @@
+const crypto = require('node:crypto');
+
 const Provider = require('../Provider')
 const { getURLMeta } = require('../../helpers/request')
 const logger = require('../../logger')
 const { adaptData, sortImages } = require('./adapter')
 const { withProviderErrorHandling } = require('../providerErrors')
 const { prepareStream } = require('../../helpers/utils')
+const { HttpError } = require('../../helpers/utils')
 
 const got = require('../../got')
 
-const getClient = async ({ token }) => (await got).extend({
-  prefixUrl: 'https://graph.facebook.com',
-  headers: {
-    authorization: `Bearer ${token}`,
-  },
-})
 
-async function getMediaUrl ({ token, id }) {
-  const body = await (await getClient({ token })).get(String(id), { searchParams: { fields: 'images' }, responseType: 'json' }).json()
+async function runRequestBatch({ secret, token, requests }) {
+  // https://developers.facebook.com/docs/facebook-login/security/#appsecret
+  // couldn't get `appsecret_time` working, but it seems to be working without it
+  // const time = Math.floor(Date.now() / 1000)
+  const appSecretProof = crypto.createHmac('sha256', secret)
+    // .update(`${token}|${time}`)
+    .update(token)
+    .digest('hex');
+
+  const form = {
+    access_token: token,
+    appsecret_proof: appSecretProof,
+    // appsecret_time: String(time),
+    batch: JSON.stringify(requests),
+  }
+
+  const responsesRaw = await (await got).post('https://graph.facebook.com', { form }).json()
+
+  const responses = responsesRaw.map((response) => ({ ...response, body: JSON.parse(response.body) }))
+
+  const errorResponse = responses.find((response) => response.code !== 200)
+  if (errorResponse) {
+    throw new HttpError({ statusCode: errorResponse.code, responseJson: errorResponse.body })
+  }
+
+  return responses
+}
+
+async function getMediaUrl ({ secret, token, id }) {
+  const [{ body }] = await runRequestBatch({
+    secret,
+    token,
+    requests: [
+      { method: 'GET', relative_url: `${id}?${new URLSearchParams({ fields: 'images' }).toString()}` },
+    ],
+  });
+
   const sortedImages = sortImages(body.images)
   return sortedImages[sortedImages.length - 1].source
-}
+}  
+
 
 /**
  * Adapter for API https://developers.facebook.com/docs/graph-api/using-graph-api/
@@ -40,19 +73,24 @@ class Facebook extends Provider {
         qs.fields = 'icon,images,name,width,height,created_time'
       }
 
-      const client = await getClient({ token })
+      const [response1, response2] = await runRequestBatch({
+        secret: this.secret,
+        token,
+        requests: [
+          { method: 'GET', relative_url: `me?${new URLSearchParams({ fields: 'email' }).toString()}` },
+          { method: 'GET', relative_url: `${path}?${new URLSearchParams(qs)}` },
+        ],
+      });
 
-      const [{ email }, list] = await Promise.all([
-        client.get('me', { searchParams: { fields: 'email' }, responseType: 'json' }).json(),
-        client.get(path, { searchParams: qs, responseType: 'json' }).json(),
-      ])
+      const { email } = response1.body
+      const list = response2.body
       return adaptData(list, email, directory, query)
     })
   }
 
   async download ({ id, token }) {
     return this.#withErrorHandling('provider.facebook.download.error', async () => {
-      const url = await getMediaUrl({ token, id })
+      const url = await getMediaUrl({ secret: this.secret, token, id })
       const stream = (await got).stream.get(url, { responseType: 'json' })
       await prepareStream(stream)
       return { stream }
@@ -68,7 +106,7 @@ class Facebook extends Provider {
 
   async size ({ id, token }) {
     return this.#withErrorHandling('provider.facebook.size.error', async () => {
-      const url = await getMediaUrl({ token, id })
+      const url = await getMediaUrl({ secret: this.secret, token, id })
       const { size } = await getURLMeta(url)
       return size
     })
@@ -76,7 +114,14 @@ class Facebook extends Provider {
 
   async logout ({ token }) {
     return this.#withErrorHandling('provider.facebook.logout.error', async () => {
-      await (await getClient({ token })).delete('me/permissions', { responseType: 'json' }).json()
+      await runRequestBatch({
+        secret: this.secret,
+        token,
+        requests: [
+          { method: 'DELETE', relative_url: 'me/permissions' },
+        ],
+      });
+  
       return { revoked: true }
     })
   }

+ 3 - 2
packages/@uppy/companion/src/server/provider/index.js

@@ -42,7 +42,7 @@ module.exports.getProviderMiddleware = (providers, grantConfig) => {
   const middleware = (req, res, next, providerName) => {
     const ProviderClass = providers[providerName]
     if (ProviderClass && validOptions(req.companion.options)) {
-      const { allowLocalUrls } = req.companion.options
+      const { allowLocalUrls, providerOptions } = req.companion.options
       const { oauthProvider } = ProviderClass
 
       let providerGrantConfig
@@ -52,7 +52,8 @@ module.exports.getProviderMiddleware = (providers, grantConfig) => {
         req.companion.providerGrantConfig = providerGrantConfig
       }
 
-      req.companion.provider = new ProviderClass({ providerName, providerGrantConfig, allowLocalUrls })
+      const { secret } = providerOptions[providerName]
+      req.companion.provider = new ProviderClass({ secret, providerName, providerGrantConfig, allowLocalUrls })
       req.companion.providerClass = ProviderClass
     } else {
       logger.warn('invalid provider options detected. Provider will not be loaded', 'provider.middleware.invalid', req.id)

+ 1 - 1
packages/@uppy/companion/src/server/provider/providerErrors.js

@@ -43,7 +43,7 @@ async function withProviderErrorHandling({
     if (err?.name === 'HTTPError') {
       statusCode = err.response?.statusCode
       body = err.response?.body
-    } else if (err?.name === 'StreamHttpJsonError') {
+    } else if (err?.name === 'HttpError') {
       statusCode = err.statusCode
       body = err.responseJson
     }

+ 69 - 31
packages/@uppy/companion/test/__tests__/providers.js

@@ -234,29 +234,44 @@ describe('list provider files', () => {
   })
 
   test('facebook', async () => {
-    nock('https://graph.facebook.com').get('/me?fields=email').reply(200, {
-      name: 'Fiona Fox',
-      birthday: '01/01/1985',
-      email: defaults.USERNAME,
-    })
-    nock('https://graph.facebook.com').get('/ALBUM-ID/photos?fields=icon%2Cimages%2Cname%2Cwidth%2Cheight%2Ccreated_time').reply(200, {
-      data: [
-        {
-          images: [
+    nock('https://graph.facebook.com').post('/',
+    [
+      'access_token=token+value',
+      'appsecret_proof=ee28d8152093b877f193f5fe84a34544ec27160e7f34c7645d02930b3fa95160',
+      `batch=${encodeURIComponent('[{"method":"GET","relative_url":"me?fields=email"},{"method":"GET","relative_url":"ALBUM-ID/photos?fields=icon%2Cimages%2Cname%2Cwidth%2Cheight%2Ccreated_time"}]')}`,
+    ].join('&')
+  ).reply(200,
+    [
+      {
+        code: 200,
+        body: JSON.stringify({
+          name: 'Fiona Fox',
+          birthday: '01/01/1985',
+          email: defaults.USERNAME,
+        }),
+      },
+      {
+        code: 200,
+        body: JSON.stringify({
+          data: [
             {
-              height: 1365,
-              source: defaults.THUMBNAIL_URL,
-              width: 2048,
+              images: [
+                {
+                  height: 1365,
+                  source: defaults.THUMBNAIL_URL,
+                  width: 2048,
+                },
+              ],
+              width: 720,
+              height: 479,
+              created_time: '2015-07-17T17:26:50+0000',
+              id: defaults.ITEM_ID,
             },
           ],
-          width: 720,
-          height: 479,
-          created_time: '2015-07-17T17:26:50+0000',
-          id: defaults.ITEM_ID,
-        },
-      ],
-      paging: {},
-    })
+          paging: {},
+        }),
+      },
+    ])
 
     const { username, items, providerFixture } = await runTest('facebook')
     expect1({ username, items, providerFixture })
@@ -396,16 +411,27 @@ describe('provider file gets downloaded from', () => {
 
   test('facebook', async () => {
     // times(2) because of size request
-    nock('https://graph.facebook.com').get(`/${defaults.ITEM_ID}?fields=images`).times(2).reply(200, {
-      images: [
-        {
-          height: 1365,
-          source: defaults.THUMBNAIL_URL,
-          width: 2048,
-        },
-      ],
-      id: defaults.ITEM_ID,
-    })
+    nock('https://graph.facebook.com').post('/',
+      [
+        'access_token=token+value',
+        'appsecret_proof=ee28d8152093b877f193f5fe84a34544ec27160e7f34c7645d02930b3fa95160',
+        `batch=${encodeURIComponent('[{"method":"GET","relative_url":"DUMMY-FILE-ID?fields=images"}]')}`,
+      ].join('&')
+    ).times(2).reply(200,
+      [{
+        code: 200,
+        body: JSON.stringify({
+          images: [
+            {
+              height: 1365,
+              source: defaults.THUMBNAIL_URL,
+              width: 2048,
+            },
+          ],
+          id: defaults.ITEM_ID,
+        }),
+      }])
+
     await runTest('facebook')
   })
 
@@ -492,7 +518,19 @@ describe('logout of provider', () => {
   })
 
   test('facebook', async () => {
-    nock('https://graph.facebook.com').delete('/me/permissions').reply(200, {})
+        // times(2) because of size request
+        nock('https://graph.facebook.com').post('/',
+        [
+          'access_token=token+value',
+          'appsecret_proof=ee28d8152093b877f193f5fe84a34544ec27160e7f34c7645d02930b3fa95160',
+          `batch=${encodeURIComponent('[{"method":"DELETE","relative_url":"me/permissions"}]')}`,
+        ].join('&')
+      ).reply(200,
+        [{
+          code: 200,
+          body: JSON.stringify({}),
+        }])
+  
     await runTest('facebook')
   })
 

+ 3 - 0
packages/@uppy/companion/test/mockserver.js

@@ -31,6 +31,9 @@ const defaultEnv = {
   COMPANION_INSTAGRAM_KEY: 'instagram_key',
   COMPANION_INSTAGRAM_SECRET: 'instagram_secret',
 
+  COMPANION_FACEBOOK_KEY: 'facebook_key',
+  COMPANION_FACEBOOK_SECRET: 'facebook_secret',
+
   COMPANION_ZOOM_KEY: localZoomKey,
   COMPANION_ZOOM_SECRET: localZoomSecret,
   COMPANION_ZOOM_VERIFICATION_TOKEN: localZoomVerificationToken,