From 1e477c1c8ec75a7cdf22c408cc2472d9a93c5ac5 Mon Sep 17 00:00:00 2001 From: Oliver Cullimore Date: Mon, 2 Dec 2024 20:46:25 +0000 Subject: [PATCH] feat: Upgrade to Instagram API with Instagram Login --- .editorconfig | 12 + .gitignore | 4 +- .prettierrc | 6 + README.md | 69 ++-- index.js | 128 -------- package-lock.json | 728 +++++++++++++++++++++++++++++++++++++++++- package.json | 34 +- src/index.js | 147 +++++++++ wrangler.toml.example | 18 ++ 9 files changed, 986 insertions(+), 160 deletions(-) create mode 100644 .editorconfig create mode 100644 .prettierrc delete mode 100644 index.js create mode 100644 src/index.js create mode 100644 wrangler.toml.example diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a727df3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = tab +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.yml] +indent_style = space diff --git a/.gitignore b/.gitignore index ef7fdd7..5e5a154 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ wrangler.toml node_modules dist -worker \ No newline at end of file +worker +.wrangler +.dev.vars diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5c7b5d3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 140, + "singleQuote": true, + "semi": true, + "useTabs": true +} diff --git a/README.md b/README.md index a21c482..b802f25 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,66 @@ -# Multi-Tenant Instagram Basic Display on CloudFlare Workers -We've had to set up quite a few Basic Display instagram connectors for our clients. +# Multi-Tenant Instagram API with Instagram Login on Cloudflare Workers -We've reinvented the wheel a few times, and grew tired of it. So we made this multi-tenant CloudFlare Worker to handle instagram basic displays. +> This repository uses the new [Instagram API with Instagram Login](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login) as the [Instagram Basic Display API](https://developers.facebook.com/docs/instagram-basic-display-api) was deprecated on 4th December 2024. See this [blog post](https://developers.facebook.com/blog/post/2024/09/04/update-on-instagram-basic-display-api/) for further info. + +We've had to set up quite a few Instagram feed connectors for our clients. + +We've reinvented the wheel a few times, and grew tired of it. So we made this multi-tenant CloudFlare Worker to handle Instagram feeds. + +## Usage + +Ensure you have [Node.js & NPM](https://nodejs.org/en/download) installed, then clone the repo locally and install the dependancies: -## Setting it up -Once deployed to Cloudflare, create a new secret for each of your clients. For example, if your client is `alexleclair`, do: ``` -wranger secret put TOKEN_ALEXLECLAIR +npm install ``` -and paste in your [Basic Display API token](https://developers.facebook.com/docs/instagram-basic-display-api/) -Instagram data will then be available at : `https://..workers.dev/alexleclair` +Then create a new KV namespace "IG-Worker" or a name of your choosing then copy the `id` that's shown: +``` +npx wrangler kv namespace create "IG-Worker" +``` -## Allowing user login (Advanced use case) -If you want, you can also use the Instagram Login flow and save tokens in Workers KV. +Copy the `wrangler.toml.example` file to `wrangler.toml` and replace `{ID}` under the `[[kv_namespaces]]` section with the `id` you copied above. -To do so, first create a new KV namespace within Cloudflare and copy its ID. -Next, open `wrangler.toml` and add the following configuration at the bottom of the file, taking care to change `{ID}` to the ID you copied from Cloudflare. +Deploy the initial app ready to configure: ``` -kv_namespaces = [ - { binding = "graphConfig", id = "{ID}", preview_id = "{ID}" } -] +npm run deploy ``` -Once done, head over to Facebook Developers and find the app you created in the "Setting it up" section of this README. -In the left sidebar, under PRODUCTS, click on `Instagram Basic Display`, then `Basic Display`. +Next ensure you have a [Facebook Developer Business Type App](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/create-a-meta-app-with-instagram) set up with the `Instagram` product added (return to these instructions before the `Generate access tokens` step if following the linked Meta docs). + +Then in the left sidebar, under `Products`, click on `Instagram`, then `API setup with Instagram business login`. + +Copy your `Instagram app ID`, and set it up in your Worker as a secret: -Copy your Instagram App Id, and set it up in your Worker as a secret: ``` -wrangler secret put APP_ID +npx wrangler secret put APP_ID ``` -Do the same thing with the app secret: +Do the same for the app secret: + ``` -wrangler secret put APP_SECRET +npx wrangler secret put APP_SECRET ``` -Once published to cloudflare (`wrangler publish`), head over to your Worker's URL: `https://{WORKER_NAME}.{WORKER_SUBDOMAIN}.workers.dev`, which will automatically redirect you to the Instagram login flow. +Then start the wizard within the `Set up Instagram business login` section below the App ID & Secret. + +Enter your Worker's URL: `https://{WORKER_NAME}.{WORKER_SUBDOMAIN}.workers.dev` as the OAuth redirect URI when prompted. + +Then head over the Worker's URL which will automatically redirect you to the Instagram login flow to set up your first tenant's connection. + +Once you've gone through the login flow, (and assuming you were setup as a tester if the app is in development mode), you will be able to fetch their media through `https://{WORKER_NAME}.{WORKER_SUBDOMAIN}.workers.dev/{USERNAME}`, where `{USERNAME}` is their Instagram username. + +## Development + +Follow the Usage steps above except: + +- Add the `--preview` flag to `npx wrangler kv namespace create "IG-Worker" --preview` command +- Replace `{PREVIEW_ID}` under the `[[kv_namespaces]]` section instead with the `preview_id` generated. + +Add `https://localhost:8787` into the OAuth redirect URIs whitelist within the `Set up Instagram business login` section of the Facebook Developer App. + +Run `npm run dev` to start a development server. -Once you've gone through the login flow, (and assuming you were setup as a tester if the app is in development mode), you will be able to fetch your media through `https://{WORKER_NAME}.{WORKER_SUBDOMAIN}.workers.dev/{USERNAME}`, where `{USERNAME}` is your Instagram username. \ No newline at end of file +Open a browser to https://localhost:8787 to see the development server worker in action (accepting the self-signed certificate). diff --git a/index.js b/index.js deleted file mode 100644 index 003eb66..0000000 --- a/index.js +++ /dev/null @@ -1,128 +0,0 @@ -const appConfig = { - id: typeof APP_ID === 'undefined' ? '' : APP_ID, - secret: typeof APP_SECRET === 'undefined' ? '' : APP_SECRET -} - -addEventListener('fetch', event => { - event.respondWith(handleRequest(event)) -}) - -async function handleRequest(event) { - const request = event.request - const url = new URL(request.url) - const searchParams = new URLSearchParams(url.search) - const count = (searchParams.get('count') || 15) * 1 - const callback = searchParams.get('callback') || null - const paths = url.pathname.split('/') - if (paths.length < 2 || !paths[1]) { - console.log('Instagram Login Flow') - try { - if (searchParams.get('code')) { - console.log('We have a code', searchParams.get('code')) - - const secretExchange = await fetch(`https://api.instagram.com/oauth/access_token`, { - method: 'post', - body: new URLSearchParams({ - client_id: appConfig.id, - client_secret: appConfig.secret, - grant_type: 'authorization_code', - redirect_uri: `https://${url.host}/`, - code: searchParams.get('code') - }) - }) - console.log('Got secret exchange data') - - const exchangeJson = await secretExchange.json() - console.log(exchangeJson) - - // Refresh the token - const tokenExchange = await fetch(`https://graph.instagram.com/access_token?grant_type=ig_exchange_token&client_secret=${encodeURIComponent(appConfig.secret)}&access_token=${encodeURIComponent(exchangeJson.access_token)}`) - const tokenExchangeJson = await tokenExchange.json() - const longLivedToken = tokenExchangeJson.access_token - const userProfile = await (await fetch(`https://graph.instagram.com/me?fields=id,username&access_token=${longLivedToken}`)).json() - if (userProfile && userProfile.username) { - await graphConfig.put(userProfile.username.toUpperCase(), JSON.stringify({ - ...userProfile, - ...tokenExchangeJson, - updated_at: (new Date()).getTime() - })) - } - return new Response(`Insagram Graph

Everything is looking good!

Your media endpoint is ready to be used and can be accessed through https://${url.host}/${userProfile.username}!

`, { - headers: { - 'Content-Type': 'text/html' - } - }) - } - } catch (e) { - console.log(e) - return new Response(`Insagram Graph

Uh oh. Something went wrong.

Please try again later. If the issue persists, double check your application configuration and that your Instagram account is setup as a tester if your application is in development mode.

`, { - headers: { - 'Content-Type': 'text/html' - } - }) - } - return new Response('', { - headers: { - 'Location': `https://api.instagram.com/oauth/authorize?client_id=${appConfig.id}&redirect_uri=${encodeURIComponent(`https://${url.host}/`)}&scope=user_profile,user_media&response_type=code` - }, - status: 302 - }) - } - const projectId = `token_${paths[1]}`.toUpperCase() - - let token = (this && this[projectId]) || null - if (!token && typeof graphConfig !== 'undefined') { - let config = null; - try { - console.log('Config?') - config = JSON.parse(await graphConfig.get(paths[1].toUpperCase())) - } catch (e) { - console.error('Error',e) - } - console.log('Got config', config) - token = config && config.access_token - } - - if (!token) { - return new Response(JSON.stringify({ - status: 'error', - message: 'Unknown token' - }), { - headers: { - 'Content-Type': 'application/json' - }, - status: 400 - }); - } - - let cache = caches.default - - const time = Math.floor((new Date()).getTime() / (1000*10*60)) - const cacheUrl = new URL(request.url) - cacheUrl.pathname = `/${paths[1]}/${time}` - const cacheKey = new Request(cacheUrl.toString(), request) - let response = await cache.match(cacheKey) - if (!response) { - const instagramUrl = `https://graph.instagram.com/me/media?fields=id,username,media_url,timestamp,media_type,thumbnail_url,caption,permalink&access_token=${token}&limit=100` - const refreshUrl = `https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=${token}` - const instagramData = await fetch(instagramUrl) - const instagramJson = await instagramData.json() - delete instagramJson.paging - instagramJson.fetch_time = (new Date()).getTime() - instagramJson.cache_key = cacheUrl.pathname - response = new Response(JSON.stringify(instagramJson)) - response.headers.set('Access-Control-Allow-Origin', '*') - response.headers.set('Content-Type', 'application/json; charset=utf-8') - event.waitUntil(cache.put(cacheKey, response.clone())) - event.waitUntil(fetch(refreshUrl)) - } - const dataJson = await response.json() - if (dataJson && dataJson.data && dataJson.data.length) { - dataJson.data = dataJson.data.slice(0, count) - } - const resData = `${callback ? `${callback}(` : ''}${JSON.stringify(dataJson)}${callback ? ');' : ''}` - const dataResponse = new Response(resData) - dataResponse.headers.set('Access-Control-Allow-Origin', '*') - dataResponse.headers.set('Content-Type', callback ? 'text/javascript; charset=utf-8' : 'application/json; charset=utf-8') - return dataResponse -} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 63ef634..3fa584e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,729 @@ { "name": "instagram-graph-cfworker", - "version": "1.0.0", - "lockfileVersion": 1 + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "instagram-graph-cfworker", + "version": "2.0.0", + "license": "MIT", + "devDependencies": { + "wrangler": "^3.60.3" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20241106.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-shared": { + "version": "0.9.0", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0", + "zod": "^3.22.3" + }, + "engines": { + "node": ">=16.7.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@types/node": { + "version": "22.10.1", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "dev": true, + "license": "MIT" + }, + "node_modules/capnp-ts": { + "version": "0.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "tslib": "^2.2.0" + } + }, + "node_modules/chokidar": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.3.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/defu": { + "version": "6.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.17.19", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "dev": true, + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/hasown": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/itty-time": { + "version": "1.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/mime": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20241106.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "^8.8.0", + "acorn-walk": "^8.2.0", + "capnp-ts": "^0.7.0", + "exit-hook": "^2.2.1", + "glob-to-regexp": "^0.4.1", + "stoppable": "^1.1.0", + "undici": "^5.28.4", + "workerd": "1.20241106.1", + "ws": "^8.18.0", + "youch": "^3.2.2", + "zod": "^3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/mustache": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/nanoid": { + "version": "3.3.8", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-forge": { + "version": "1.3.1", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/ohash": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "dev": true, + "license": "Unlicense" + }, + "node_modules/readdirp": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-inject/node_modules/magic-string": { + "version": "0.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.1.8", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "license": "0BSD" + }, + "node_modules/ufo": { + "version": "1.5.4", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.28.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "dev": true, + "license": "MIT" + }, + "node_modules/unenv": { + "name": "unenv-nightly", + "version": "2.0.0-20241121-161142-806b5c0", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "ohash": "^1.1.4", + "pathe": "^1.1.2", + "ufo": "^1.5.4" + } + }, + "node_modules/workerd": { + "version": "1.20241106.1", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20241106.1", + "@cloudflare/workerd-darwin-arm64": "1.20241106.1", + "@cloudflare/workerd-linux-64": "1.20241106.1", + "@cloudflare/workerd-linux-arm64": "1.20241106.1", + "@cloudflare/workerd-windows-64": "1.20241106.1" + } + }, + "node_modules/wrangler": { + "version": "3.91.0", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/workers-shared": "0.9.0", + "@esbuild-plugins/node-globals-polyfill": "^0.2.3", + "@esbuild-plugins/node-modules-polyfill": "^0.2.2", + "blake3-wasm": "^2.1.5", + "chokidar": "^4.0.1", + "date-fns": "^4.1.0", + "esbuild": "0.17.19", + "itty-time": "^1.0.6", + "miniflare": "3.20241106.1", + "nanoid": "^3.3.3", + "path-to-regexp": "^6.3.0", + "resolve": "^1.22.8", + "resolve.exports": "^2.0.2", + "selfsigned": "^2.0.1", + "source-map": "^0.6.1", + "unenv": "npm:unenv-nightly@2.0.0-20241121-161142-806b5c0", + "workerd": "1.20241106.1", + "xxhash-wasm": "^1.0.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20241106.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/youch": { + "version": "3.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.23.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } } diff --git a/package.json b/package.json index f3935d4..053ef7b 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,33 @@ { "name": "instagram-graph-cfworker", - "version": "1.0.0", - "description": "", - "main": "index.js", + "version": "2.0.0", + "description": "Multi-Tenant Instagram API with Instagram Login on Cloudflare Workers", + "keywords": [ + "instagram-api", + "cloudflare-worker" + ], + "homepage": "https://github.com/workwithpact/Instagram-Graph-CFWorker", + "bugs": { + "url": "https://github.com/workwithpact/Instagram-Graph-CFWorker/issues" + }, + "license": "MIT", + "contributors": [ + { + "name": "alexleclair" + }, + { + "name": "OliverCullimore" + } + ], + "repository": { + "type": "git", + "url": "https://github.com/workwithpact/Instagram-Graph-CFWorker.git" + }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "deploy": "wrangler deploy", + "dev": "wrangler dev --local-protocol https" }, - "author": "", - "license": "ISC" + "devDependencies": { + "wrangler": "^3.60.3" + } } diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..efa491c --- /dev/null +++ b/src/index.js @@ -0,0 +1,147 @@ +export default { + async fetch(request, env, ctx) { + const appConfig = { + id: typeof env.APP_ID === 'undefined' ? '' : env.APP_ID, + secret: typeof env.APP_SECRET === 'undefined' ? '' : env.APP_SECRET, + }; + const url = new URL(request.url); + const searchParams = new URLSearchParams(url.search); + const count = (searchParams.get('count') || 15) * 1; + const callback = searchParams.get('callback') || null; + const paths = url.pathname.split('/'); + if (paths.length < 2 || !paths[1]) { + console.debug('Instagram Login Flow'); + try { + if (searchParams.get('code')) { + console.debug('We have a code', searchParams.get('code')); + + const secretExchange = await fetch(`https://api.instagram.com/oauth/access_token`, { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: new URLSearchParams({ + client_id: appConfig.id, + client_secret: appConfig.secret, + grant_type: 'authorization_code', + redirect_uri: `https://${url.host}/`, + code: searchParams.get('code'), + }), + }); + console.debug('Got secret exchange data'); + + const exchangeJson = await secretExchange.json(); + console.debug(exchangeJson); + + // Refresh the token + const tokenExchange = await fetch( + `https://graph.instagram.com/access_token?grant_type=ig_exchange_token&client_secret=${encodeURIComponent( + appConfig.secret + )}&access_token=${encodeURIComponent(exchangeJson.access_token)}` + ); + const tokenExchangeJson = await tokenExchange.json(); + const longLivedToken = tokenExchangeJson.access_token; + const userProfile = await ( + await fetch(`https://graph.instagram.com/me?fields=id,username&access_token=${longLivedToken}`) + ).json(); + if (userProfile && userProfile.username) { + await env.graphConfig.put( + userProfile.username.toUpperCase(), + JSON.stringify({ + ...userProfile, + ...tokenExchangeJson, + updated_at: new Date().getTime(), + }) + ); + } + return new Response( + `Instagram Graph

Everything is looking good!

Your media endpoint is ready to be used and can be accessed through https://${url.host}/${userProfile.username}!

`, + { + headers: { + 'Content-Type': 'text/html', + }, + } + ); + } + } catch (e) { + console.debug(e); + return new Response( + `Instagram Graph

Uh oh. Something went wrong.

Please try again later. If the issue persists, double check your application configuration and that your Instagram account is setup as a tester if your application is in development mode.

`, + { + headers: { + 'Content-Type': 'text/html', + }, + } + ); + } + return new Response('', { + headers: { + Location: `https://www.instagram.com/oauth/authorize?client_id=${appConfig.id}&redirect_uri=${encodeURIComponent( + `https://${url.host}/` + )}&scope=instagram_business_basic&response_type=code`, + }, + status: 302, + }); + } + const projectId = `token_${paths[1]}`.toUpperCase(); + + console.debug(env); + + let token = (this && this[projectId]) || null; + if (!token && typeof env.graphConfig !== 'undefined') { + let config = null; + try { + console.debug('Config?'); + config = JSON.parse(await env.graphConfig.get(paths[1].toUpperCase())); + } catch (e) { + console.error('Error', e); + } + console.debug('Got config', config); + token = config && config.access_token; + } + + if (!token) { + return new Response( + JSON.stringify({ + status: 'error', + message: 'Unknown token', + }), + { + headers: { + 'Content-Type': 'application/json', + }, + status: 400, + } + ); + } + + let cache = caches.default; + + const time = Math.floor(new Date().getTime() / (1000 * 10 * 60)); + const cacheUrl = new URL(request.url); + cacheUrl.pathname = `/${paths[1]}/${time}`; + const cacheKey = new Request(cacheUrl.toString(), request); + let response = await cache.match(cacheKey); + if (!response) { + const instagramUrl = `https://graph.instagram.com/me/media?fields=id,username,media_url,timestamp,media_type,thumbnail_url,caption,permalink&access_token=${token}&limit=100`; + const refreshUrl = `https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=${token}`; + const instagramData = await fetch(instagramUrl); + const instagramJson = await instagramData.json(); + delete instagramJson.paging; + instagramJson.fetch_time = new Date().getTime(); + instagramJson.cache_key = cacheUrl.pathname; + response = new Response(JSON.stringify(instagramJson)); + response.headers.set('Access-Control-Allow-Origin', '*'); + response.headers.set('Content-Type', 'application/json; charset=utf-8'); + ctx.waitUntil(cache.put(cacheKey, response.clone())); + ctx.waitUntil(fetch(refreshUrl)); + } + const dataJson = await response.json(); + if (dataJson && dataJson.data && dataJson.data.length) { + dataJson.data = dataJson.data.slice(0, count); + } + const resData = `${callback ? `${callback}(` : ''}${JSON.stringify(dataJson)}${callback ? ');' : ''}`; + const dataResponse = new Response(resData); + dataResponse.headers.set('Access-Control-Allow-Origin', '*'); + dataResponse.headers.set('Content-Type', callback ? 'text/javascript; charset=utf-8' : 'application/json; charset=utf-8'); + return dataResponse; + }, +}; diff --git a/wrangler.toml.example b/wrangler.toml.example new file mode 100644 index 0000000..a45524e --- /dev/null +++ b/wrangler.toml.example @@ -0,0 +1,18 @@ +#:schema node_modules/wrangler/config-schema.json +name = "instagram-graph-cfworker" +main = "src/index.js" +compatibility_date = "2024-11-27" +compatibility_flags = ["nodejs_compat"] + +# Workers Logs +# Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/ +# Configuration: https://developers.cloudflare.com/workers/observability/logs/workers-logs/#enable-workers-logs +# [observability] +# enabled = true + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces +[[kv_namespaces]] +binding = "graphConfig" +id = "{ID}" +# preview_id = "{PREVIEW_ID}" # Developement use only