diff --git a/packages/workers.new/src/index.ts b/packages/workers.new/src/index.ts index ae5990ae..e1a6c2b7 100644 --- a/packages/workers.new/src/index.ts +++ b/packages/workers.new/src/index.ts @@ -1,4 +1,3 @@ -import { promisify } from 'util'; // Redirect https://workers.new/ requests to IDE. // Redirect https://workers.new/*? requests to dashboard. // Similar to the concept of https://docs.new. @@ -8,6 +7,10 @@ type Redirects = Record; // stackblitz repository source const source = 'github/cloudflare/templates/tree/main'; +// deploy with cloudflare source + +const src = 'https://github.com/cloudflare/templates/tree/main'; + const redirects: Redirects = { '/durable-objects': ['worker-durable-objects', 'index.js', 'Workers Durable Objects counter'], '/example-wordle': ['worker-example-wordle', 'src/index.ts', 'Workers Wordle example'], @@ -16,9 +19,6 @@ const redirects: Redirects = { 'src/index.ts', 'Workers Request Scheduler', ], - '/router': ['worker-router', 'index.js', 'Workers router'], - '/typescript': ['worker-typescript', 'src/index.ts', 'Workers TypeScript'], - '/websocket': ['worker-websocket', 'index.js', 'Workers WebSocket'], '/websocket-durable-objects': [ 'worker-websocket-durable-objects', 'src/index.ts', @@ -90,18 +90,13 @@ const redirects: Redirects = { 'src/index.html', 'Play live video (using WHEP) over WebRTC with Cloudflare Stream', ], - '/stream/stripe-checkout': [ - 'stream/auth/stripe', - 'functions/api/success.js', - 'Example of using Cloudflare Stream and Stripe Checkout to paywall content', - ], }; const worker: ExportedHandler = { fetch(request) { const { pathname } = new URL(request.url); - if (pathname === '/list') { + if (pathname === '/templates') { return new Response(getListHTML(redirects), { headers: { 'content-type': 'text/html' } }); } @@ -128,98 +123,145 @@ function getRedirectUrlForPathname(pathname: string): string | undefined { function getListHTML(redirects: Redirects) { return ` - + + + -

List of workers.new Redirects

-

A collection of Stackblitz templates ready for you to use!

+ +

Cloudflare Workers Templates

+

Ready to use templates to start building applications on Cloudflare Workers.

+

Want to contribute a template? Send a PR to the templates repository.

`; diff --git a/packages/workers.new/wrangler.toml b/packages/workers.new/wrangler.toml index 3e7dbfa3..d6c66fa0 100644 --- a/packages/workers.new/wrangler.toml +++ b/packages/workers.new/wrangler.toml @@ -1,3 +1,4 @@ name = "workers-dot-new" compatibility_date = "2022-05-03" +main = "src/index.ts" workers_dev = false diff --git a/examples/pages-example-forum-app/.env.example b/pages-example-forum-app/.env.example similarity index 100% rename from examples/pages-example-forum-app/.env.example rename to pages-example-forum-app/.env.example diff --git a/examples/pages-example-forum-app/.gitignore b/pages-example-forum-app/.gitignore similarity index 100% rename from examples/pages-example-forum-app/.gitignore rename to pages-example-forum-app/.gitignore diff --git a/examples/pages-example-forum-app/README.md b/pages-example-forum-app/README.md similarity index 93% rename from examples/pages-example-forum-app/README.md rename to pages-example-forum-app/README.md index 010d45d3..08d1f217 100644 --- a/examples/pages-example-forum-app/README.md +++ b/pages-example-forum-app/README.md @@ -1,5 +1,7 @@ # Forum application built with Pages Functions, Workers KV and Durable Objects +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/templates/tree/main/pages-example-forum-app) + ## Overview This example application showcases a forum application that allows authenticated users to leave comments, like comments and reply on other user’s comments using Pages Functions to handle server-side logic such as Authentication and posting comments to Workers KV. We will also use Workers KV for storage of our comment entries and Durable Objects to keep the count of likes consistent. diff --git a/examples/pages-example-forum-app/functions/api/code.ts b/pages-example-forum-app/functions/api/code.ts similarity index 100% rename from examples/pages-example-forum-app/functions/api/code.ts rename to pages-example-forum-app/functions/api/code.ts diff --git a/examples/pages-example-forum-app/functions/api/form.ts b/pages-example-forum-app/functions/api/form.ts similarity index 100% rename from examples/pages-example-forum-app/functions/api/form.ts rename to pages-example-forum-app/functions/api/form.ts diff --git a/examples/pages-example-forum-app/functions/api/getComments.ts b/pages-example-forum-app/functions/api/getComments.ts similarity index 100% rename from examples/pages-example-forum-app/functions/api/getComments.ts rename to pages-example-forum-app/functions/api/getComments.ts diff --git a/examples/pages-example-forum-app/functions/api/tsconfig.json b/pages-example-forum-app/functions/api/tsconfig.json similarity index 100% rename from examples/pages-example-forum-app/functions/api/tsconfig.json rename to pages-example-forum-app/functions/api/tsconfig.json diff --git a/examples/pages-example-forum-app/functions/api/updateComment.js b/pages-example-forum-app/functions/api/updateComment.js similarity index 100% rename from examples/pages-example-forum-app/functions/api/updateComment.js rename to pages-example-forum-app/functions/api/updateComment.js diff --git a/examples/pages-example-forum-app/package.json b/pages-example-forum-app/package.json similarity index 100% rename from examples/pages-example-forum-app/package.json rename to pages-example-forum-app/package.json diff --git a/examples/pages-example-forum-app/public/favicon.ico b/pages-example-forum-app/public/favicon.ico similarity index 100% rename from examples/pages-example-forum-app/public/favicon.ico rename to pages-example-forum-app/public/favicon.ico diff --git a/examples/pages-example-forum-app/public/index.html b/pages-example-forum-app/public/index.html similarity index 100% rename from examples/pages-example-forum-app/public/index.html rename to pages-example-forum-app/public/index.html diff --git a/examples/pages-example-forum-app/public/logo192.png b/pages-example-forum-app/public/logo192.png similarity index 100% rename from examples/pages-example-forum-app/public/logo192.png rename to pages-example-forum-app/public/logo192.png diff --git a/examples/pages-example-forum-app/public/logo512.png b/pages-example-forum-app/public/logo512.png similarity index 100% rename from examples/pages-example-forum-app/public/logo512.png rename to pages-example-forum-app/public/logo512.png diff --git a/examples/pages-example-forum-app/public/manifest.json b/pages-example-forum-app/public/manifest.json similarity index 100% rename from examples/pages-example-forum-app/public/manifest.json rename to pages-example-forum-app/public/manifest.json diff --git a/examples/pages-example-forum-app/public/robots.txt b/pages-example-forum-app/public/robots.txt similarity index 100% rename from examples/pages-example-forum-app/public/robots.txt rename to pages-example-forum-app/public/robots.txt diff --git a/examples/pages-example-forum-app/src/App.tsx b/pages-example-forum-app/src/App.tsx similarity index 100% rename from examples/pages-example-forum-app/src/App.tsx rename to pages-example-forum-app/src/App.tsx diff --git a/examples/pages-example-forum-app/src/components/AddComment.tsx b/pages-example-forum-app/src/components/AddComment.tsx similarity index 100% rename from examples/pages-example-forum-app/src/components/AddComment.tsx rename to pages-example-forum-app/src/components/AddComment.tsx diff --git a/examples/pages-example-forum-app/src/components/Comment.tsx b/pages-example-forum-app/src/components/Comment.tsx similarity index 100% rename from examples/pages-example-forum-app/src/components/Comment.tsx rename to pages-example-forum-app/src/components/Comment.tsx diff --git a/examples/pages-example-forum-app/src/components/CommentDetail.tsx b/pages-example-forum-app/src/components/CommentDetail.tsx similarity index 100% rename from examples/pages-example-forum-app/src/components/CommentDetail.tsx rename to pages-example-forum-app/src/components/CommentDetail.tsx diff --git a/examples/pages-example-forum-app/src/components/CommentList.tsx b/pages-example-forum-app/src/components/CommentList.tsx similarity index 100% rename from examples/pages-example-forum-app/src/components/CommentList.tsx rename to pages-example-forum-app/src/components/CommentList.tsx diff --git a/examples/pages-example-forum-app/src/components/CommentSection.tsx b/pages-example-forum-app/src/components/CommentSection.tsx similarity index 100% rename from examples/pages-example-forum-app/src/components/CommentSection.tsx rename to pages-example-forum-app/src/components/CommentSection.tsx diff --git a/examples/pages-example-forum-app/src/components/Loading.tsx b/pages-example-forum-app/src/components/Loading.tsx similarity index 100% rename from examples/pages-example-forum-app/src/components/Loading.tsx rename to pages-example-forum-app/src/components/Loading.tsx diff --git a/examples/pages-example-forum-app/src/components/Navbar.tsx b/pages-example-forum-app/src/components/Navbar.tsx similarity index 100% rename from examples/pages-example-forum-app/src/components/Navbar.tsx rename to pages-example-forum-app/src/components/Navbar.tsx diff --git a/examples/pages-example-forum-app/src/components/ThemeToggler.tsx b/pages-example-forum-app/src/components/ThemeToggler.tsx similarity index 100% rename from examples/pages-example-forum-app/src/components/ThemeToggler.tsx rename to pages-example-forum-app/src/components/ThemeToggler.tsx diff --git a/examples/pages-example-forum-app/src/components/shared/Button.tsx b/pages-example-forum-app/src/components/shared/Button.tsx similarity index 100% rename from examples/pages-example-forum-app/src/components/shared/Button.tsx rename to pages-example-forum-app/src/components/shared/Button.tsx diff --git a/examples/pages-example-forum-app/src/context/AuthContext.tsx b/pages-example-forum-app/src/context/AuthContext.tsx similarity index 100% rename from examples/pages-example-forum-app/src/context/AuthContext.tsx rename to pages-example-forum-app/src/context/AuthContext.tsx diff --git a/examples/pages-example-forum-app/src/index.js b/pages-example-forum-app/src/index.js similarity index 100% rename from examples/pages-example-forum-app/src/index.js rename to pages-example-forum-app/src/index.js diff --git a/examples/pages-example-forum-app/src/reportWebVitals.js b/pages-example-forum-app/src/reportWebVitals.js similarity index 100% rename from examples/pages-example-forum-app/src/reportWebVitals.js rename to pages-example-forum-app/src/reportWebVitals.js diff --git a/examples/pages-example-forum-app/src/services/authService.ts b/pages-example-forum-app/src/services/authService.ts similarity index 100% rename from examples/pages-example-forum-app/src/services/authService.ts rename to pages-example-forum-app/src/services/authService.ts diff --git a/examples/pages-example-forum-app/src/services/commentServices.ts b/pages-example-forum-app/src/services/commentServices.ts similarity index 100% rename from examples/pages-example-forum-app/src/services/commentServices.ts rename to pages-example-forum-app/src/services/commentServices.ts diff --git a/examples/pages-example-forum-app/src/setupTests.js b/pages-example-forum-app/src/setupTests.js similarity index 100% rename from examples/pages-example-forum-app/src/setupTests.js rename to pages-example-forum-app/src/setupTests.js diff --git a/examples/pages-example-forum-app/src/theme.js b/pages-example-forum-app/src/theme.js similarity index 100% rename from examples/pages-example-forum-app/src/theme.js rename to pages-example-forum-app/src/theme.js diff --git a/examples/pages-example-forum-app/src/types/index.ts b/pages-example-forum-app/src/types/index.ts similarity index 100% rename from examples/pages-example-forum-app/src/types/index.ts rename to pages-example-forum-app/src/types/index.ts diff --git a/examples/pages-example-forum-app/src/utils/dateFormatter.ts b/pages-example-forum-app/src/utils/dateFormatter.ts similarity index 100% rename from examples/pages-example-forum-app/src/utils/dateFormatter.ts rename to pages-example-forum-app/src/utils/dateFormatter.ts diff --git a/examples/pages-example-forum-app/src/utils/urlFormatter.ts b/pages-example-forum-app/src/utils/urlFormatter.ts similarity index 100% rename from examples/pages-example-forum-app/src/utils/urlFormatter.ts rename to pages-example-forum-app/src/utils/urlFormatter.ts diff --git a/examples/pages-example-forum-app/tsconfig.json b/pages-example-forum-app/tsconfig.json similarity index 100% rename from examples/pages-example-forum-app/tsconfig.json rename to pages-example-forum-app/tsconfig.json diff --git a/examples/pages-example-forum-app/worker-durable/src/index.ts b/pages-example-forum-app/worker-durable/src/index.ts similarity index 100% rename from examples/pages-example-forum-app/worker-durable/src/index.ts rename to pages-example-forum-app/worker-durable/src/index.ts diff --git a/examples/pages-example-forum-app/worker-durable/tsconfig.json b/pages-example-forum-app/worker-durable/tsconfig.json similarity index 100% rename from examples/pages-example-forum-app/worker-durable/tsconfig.json rename to pages-example-forum-app/worker-durable/tsconfig.json diff --git a/examples/pages-example-forum-app/worker-durable/wrangler.toml b/pages-example-forum-app/worker-durable/wrangler.toml similarity index 100% rename from examples/pages-example-forum-app/worker-durable/wrangler.toml rename to pages-example-forum-app/worker-durable/wrangler.toml diff --git a/pages-image-sharing/.gitignore b/pages-image-sharing/.gitignore new file mode 100644 index 00000000..7ba010c7 --- /dev/null +++ b/pages-image-sharing/.gitignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Pages +/.pages \ No newline at end of file diff --git a/pages-image-sharing/.nvmrc b/pages-image-sharing/.nvmrc new file mode 100644 index 00000000..20a6283b --- /dev/null +++ b/pages-image-sharing/.nvmrc @@ -0,0 +1 @@ +v16.7.0 diff --git a/pages-image-sharing/LICENSE b/pages-image-sharing/LICENSE new file mode 100644 index 00000000..6619a1cd --- /dev/null +++ b/pages-image-sharing/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Cloudflare + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pages-image-sharing/README.md b/pages-image-sharing/README.md new file mode 100644 index 00000000..97f616e3 --- /dev/null +++ b/pages-image-sharing/README.md @@ -0,0 +1,5 @@ +# Image sharing platform build with Cloudflare Pages + +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/templates/tree/main/pages-image-sharing) + +This example application showcases Cloudflare Pages functions powered by Cloudflare Workers diff --git a/pages-image-sharing/craco.config.js b/pages-image-sharing/craco.config.js new file mode 100644 index 00000000..990acfab --- /dev/null +++ b/pages-image-sharing/craco.config.js @@ -0,0 +1,11 @@ +module.exports = { + devServer: { + hot: false, + inline: false, + }, + style: { + postcss: { + plugins: [require('tailwindcss'), require('autoprefixer')], + }, + }, +}; diff --git a/pages-image-sharing/durable_objects/README.md b/pages-image-sharing/durable_objects/README.md new file mode 100644 index 00000000..fb4ee460 --- /dev/null +++ b/pages-image-sharing/durable_objects/README.md @@ -0,0 +1,10 @@ +# Download Counter Durable Object + +To maintain an accurate count of the number of downloads for each image, we're using Durable Objects. Each image has its own instance of the Durable Object class defined in [`./src/downloadCounter.js`](./src/downloadCounter.js). At present, wrangler v2 does not support Durable Objects, which is why this exists in its own little package. We'll update this repo when we can simplify the deployment process. + +## Publish + +```sh +npm install; +CF_ACCOUNT_ID="" npm run publish; +``` diff --git a/pages-image-sharing/durable_objects/package.json b/pages-image-sharing/durable_objects/package.json new file mode 100644 index 00000000..8b189e48 --- /dev/null +++ b/pages-image-sharing/durable_objects/package.json @@ -0,0 +1,13 @@ +{ + "name": "@images.page.dev/durable_objects", + "version": "0.1.0", + "private": true, + "license": "MIT", + "module": "./src/downloadCounter.mjs", + "scripts": { + "publish": "npx wrangler publish" + }, + "devDependencies": { + "@cloudflare/wrangler": "^1.19.5" + } +} diff --git a/pages-image-sharing/durable_objects/src/downloadCounter.mjs b/pages-image-sharing/durable_objects/src/downloadCounter.mjs new file mode 100644 index 00000000..3d9ef29e --- /dev/null +++ b/pages-image-sharing/durable_objects/src/downloadCounter.mjs @@ -0,0 +1,36 @@ +// Adapted from the example in our documentation: https://developers.cloudflare.com/workers/learning/using-durable-objects#example---counter + +const jsonResponse = (value, init = {}) => + new Response(JSON.stringify(value), { + headers: { 'Content-Type': 'application/json', ...init.headers }, + ...init, + }); + +export class DownloadCounter { + constructor(state) { + this.state = state; + // `blockConcurrencyWhile()` ensures no requests are delivered until initialization completes. + this.state.blockConcurrencyWhile(async () => { + let stored = await this.state.storage.get('value'); + this.value = stored || 0; + }); + } + + async fetch(request) { + const url = new URL(request.url); + let currentValue = this.value; + + if (url.pathname === '/increment') { + currentValue = ++this.value; + await this.state.storage.put('value', currentValue); + } + + return jsonResponse(currentValue); + } +} + +export default { + fetch() { + return new Response('This Worker creates the DownloadCounter Durable Object.'); + }, +}; diff --git a/pages-image-sharing/durable_objects/wrangler.toml b/pages-image-sharing/durable_objects/wrangler.toml new file mode 100644 index 00000000..24d4f44a --- /dev/null +++ b/pages-image-sharing/durable_objects/wrangler.toml @@ -0,0 +1,18 @@ +name = "images-download-counter" +type = "javascript" +workers_dev = true +compatibility_date = "2021-11-17" + +[build.upload] +dir = "src" +format = "modules" +main = "./downloadCounter.mjs" + +[durable_objects] +bindings = [ + { name = "DOWNLOAD_COUNTER", class_name = "DownloadCounter" } +] + +[[migrations]] +tag = "v1" +new_classes = ["DownloadCounter"] \ No newline at end of file diff --git a/pages-image-sharing/functions/admin/_middleware.ts b/pages-image-sharing/functions/admin/_middleware.ts new file mode 100644 index 00000000..12e492e7 --- /dev/null +++ b/pages-image-sharing/functions/admin/_middleware.ts @@ -0,0 +1,3 @@ +export const onRequest = () => { + new Response('Forbidden', { status: 403 }); +}; diff --git a/pages-image-sharing/functions/admin/api/setup/access.ts b/pages-image-sharing/functions/admin/api/setup/access.ts new file mode 100644 index 00000000..07d6fd7b --- /dev/null +++ b/pages-image-sharing/functions/admin/api/setup/access.ts @@ -0,0 +1,17 @@ +import { jsonResponse } from '../../../utils/jsonResponse'; + +export const onRequestPost: PagesFunction<{ IMAGES: KVNamespace }> = async ({ request, env }) => { + try { + const { accessAud } = await request.json(); + const { apiToken, accountId, imagesKey } = (await env.IMAGES.get('setup', 'json')) as Setup; + + await env.IMAGES.put('setup', JSON.stringify({ apiToken, accountId, imagesKey, accessAud })); + + return jsonResponse(true); + } catch (thrown) { + return jsonResponse( + { error: `Could not save Cloudflare Access \`aud\`: ${thrown}.` }, + { status: 500 } + ); + } +}; diff --git a/pages-image-sharing/functions/admin/api/setup/accountId.ts b/pages-image-sharing/functions/admin/api/setup/accountId.ts new file mode 100644 index 00000000..db11e71b --- /dev/null +++ b/pages-image-sharing/functions/admin/api/setup/accountId.ts @@ -0,0 +1,19 @@ +import { jsonResponse } from '../../../utils/jsonResponse'; + +export const onRequestPost: PagesFunction<{ IMAGES: KVNamespace }> = async ({ request, env }) => { + let accountId: string; + + try { + accountId = (await request.json<{ accountId: string }>()).accountId; + } catch { + return jsonResponse({ error: 'Could not parse account ID.' }, { status: 400 }); + } + + try { + const { apiToken } = (await env.IMAGES.get('setup', 'json')) as Setup; + await env.IMAGES.put('setup', JSON.stringify({ apiToken, accountId })); + return jsonResponse(true); + } catch (thrown) { + return jsonResponse({ error: `Could not select account: ${thrown}` }); + } +}; diff --git a/pages-image-sharing/functions/admin/api/setup/apiToken.ts b/pages-image-sharing/functions/admin/api/setup/apiToken.ts new file mode 100644 index 00000000..3775ddf7 --- /dev/null +++ b/pages-image-sharing/functions/admin/api/setup/apiToken.ts @@ -0,0 +1,42 @@ +import { jsonResponse } from '../../../utils/jsonResponse'; + +export const onRequestPost: PagesFunction<{ IMAGES: KVNamespace }> = async ({ request, env }) => { + let apiToken: string; + let response: Response; + + try { + apiToken = (await request.json<{ apiToken: string }>()).apiToken; + } catch { + return jsonResponse({ error: 'Could not parse API token.' }, { status: 400 }); + } + + try { + response = await fetch(`https://api.cloudflare.com/client/v4/accounts`, { + headers: { Authorization: `Bearer ${apiToken}` }, + }); + const data = await response.json<{ + result: { id: string; name: string }[]; + errors: unknown[]; + }>(); + if (data.errors.length > 0) throw data.errors; + + if (data.result.length === 1) { + const accountId = data.result[0].id; + await env.IMAGES.put('setup', JSON.stringify({ apiToken, accountId })); + return jsonResponse({ accountId }); + } else { + await env.IMAGES.put('setup', JSON.stringify({ apiToken })); + } + + return jsonResponse({ + accounts: data.result.map(({ id, name }) => ({ id, name })), + }); + } catch (thrown) { + return jsonResponse( + { + error: `Could not load accounts: ${JSON.stringify(thrown)}.`, + }, + { status: 500 } + ); + } +}; diff --git a/pages-image-sharing/functions/admin/api/setup/images.ts b/pages-image-sharing/functions/admin/api/setup/images.ts new file mode 100644 index 00000000..0efa622d --- /dev/null +++ b/pages-image-sharing/functions/admin/api/setup/images.ts @@ -0,0 +1,91 @@ +import { jsonResponse } from '../../../utils/jsonResponse'; + +const variantPayloads = [ + { + id: 'blurred', + neverRequireSignedURLs: true, + options: { + blur: 15, + fit: 'scale-down', + height: 360, + metadata: 'none', + width: 360, + }, + }, + { + id: 'preview', + options: { + fit: 'scale-down', + height: 360, + metadata: 'none', + width: 360, + }, + }, + { + id: 'highres', + options: { + fit: 'scale-down', + height: 2000, + metadata: 'none', + width: 2000, + }, + }, +]; + +export const onRequestPost: PagesFunction<{ IMAGES: KVNamespace }> = async ({ request, env }) => { + let apiToken: string, accountId: string; + + try { + const setup = (await env.IMAGES.get('setup', 'json')) as Setup; + apiToken = setup.apiToken; + accountId = setup.accountId; + } catch (thrown) { + return jsonResponse( + { error: `Could not get setup configuration: ${thrown}.` }, + { status: 500 } + ); + } + + try { + const keysResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1/keys`, + { + headers: { Authorization: `Bearer ${apiToken}` }, + } + ); + const { + result: { + keys: [{ value: imagesKey }], + }, + } = await keysResponse.json(); + + await env.IMAGES.put('setup', JSON.stringify({ apiToken, accountId, imagesKey })); + + const variantResponsePromises = await Promise.allSettled( + variantPayloads.map(async variantPayload => + fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1/variants`, { + method: 'POST', + body: JSON.stringify(variantPayload), + headers: { + Authorization: `Bearer ${apiToken}`, + }, + }) + ) + ); + + for (const responsePromise of variantResponsePromises) { + if (responsePromise.status === 'rejected' || !responsePromise.value.ok) { + throw new Error('Could not configure a variant.'); + } + } + + return jsonResponse(true); + } catch (thrown) { + return jsonResponse( + { + error: `Could not configure Cloudflare Images: ${thrown}.`, + }, + { status: 500 } + ); + } +}; diff --git a/pages-image-sharing/functions/admin/api/upload.ts b/pages-image-sharing/functions/admin/api/upload.ts new file mode 100644 index 00000000..a5a2be1f --- /dev/null +++ b/pages-image-sharing/functions/admin/api/upload.ts @@ -0,0 +1,75 @@ +import { jsonResponse } from '../../utils/jsonResponse'; +import { parseFormDataRequest } from '../../utils/parseFormDataRequest'; +import { IMAGE_KEY_PREFIX } from '../../utils/constants'; + +export const onRequestPost: PagesFunction<{ + IMAGES: KVNamespace; + DOWNLOAD_COUNTER: DurableObjectNamespace; +}> = async ({ request, env }) => { + try { + const { apiToken, accountId } = (await env.IMAGES.get('setup', 'json')) as Setup; + + // Compatibility dates aren't yet possible to set: https://developers.cloudflare.com/workers/platform/compatibility-dates#formdata-parsing-supports-file + const formData = (await parseFormDataRequest(request)) as FormData; + formData.set('requireSignedURLs', 'true'); + const alt = formData.get('alt') as string; + formData.delete('alt'); + const isPrivate = formData.get('isPrivate') === 'on'; + formData.delete('isPrivate'); + + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`, + { + method: 'POST', + body: formData, + headers: { + Authorization: `Bearer ${apiToken}`, + }, + } + ); + + const { + result: { + id, + filename: name, + uploaded, + variants: [url], + }, + } = await response.json<{ + result: { + id: string; + filename: string; + uploaded: string; + requireSignedURLs: boolean; + variants: string[]; + }; + }>(); + + const downloadCounterId = env.DOWNLOAD_COUNTER.newUniqueId().toString(); + + const metadata: ImageMetadata = { + id, + previewURLBase: url.split('/').slice(0, -1).join('/'), + name, + alt, + uploaded, + isPrivate, + downloadCounterId, + }; + + await env.IMAGES.put(`${IMAGE_KEY_PREFIX}uploaded:${uploaded}`, 'Values stored in metadata.', { + metadata, + }); + await env.IMAGES.put(`${IMAGE_KEY_PREFIX}${id}`, JSON.stringify(metadata)); + + return jsonResponse(true); + } catch { + return jsonResponse( + { + error: + 'Could not upload image. Have you completed setup? Is it less than 10 MB? Is it a supported file type (PNG, JPEG, GIF, WebP)?', + }, + { status: 500 } + ); + } +}; diff --git a/pages-image-sharing/functions/api/download.ts b/pages-image-sharing/functions/api/download.ts new file mode 100644 index 00000000..95bc3bcc --- /dev/null +++ b/pages-image-sharing/functions/api/download.ts @@ -0,0 +1,30 @@ +import { IMAGE_KEY_PREFIX } from '../utils/constants'; +import { generateSignedURL } from '../utils/generateSignedURL'; + +export const onRequestGet: PagesFunction<{ + IMAGES: KVNamespace; + DOWNLOAD_COUNTER: DurableObjectNamespace; +}> = async ({ request, env }) => { + const url = new URL(request.url); + const id = url.searchParams.get('id'); + const { previewURLBase, downloadCounterId } = (await env.IMAGES.get( + `${IMAGE_KEY_PREFIX}${id}`, + 'json' + )) as ImageMetadata; + const { imagesKey } = (await env.IMAGES.get('setup', 'json')) as Setup; + + const hexId = env.DOWNLOAD_COUNTER.idFromString(downloadCounterId); + const downloadCounter = env.DOWNLOAD_COUNTER.get(hexId); + + await downloadCounter.fetch('https://images.pages.dev/increment'); + + return new Response(null, { + headers: { + Location: await generateSignedURL({ + url: `${previewURLBase}/highres`, + imagesKey, + }), + }, + status: 302, + }); +}; diff --git a/pages-image-sharing/functions/api/images.ts b/pages-image-sharing/functions/api/images.ts new file mode 100644 index 00000000..96319610 --- /dev/null +++ b/pages-image-sharing/functions/api/images.ts @@ -0,0 +1,60 @@ +import { jsonResponse } from '../utils/jsonResponse'; +import { IMAGE_KEY_PREFIX } from '../utils/constants'; +import { generateSignedURL } from '../utils/generateSignedURL'; + +export const onRequestGet: PagesFunction<{ + IMAGES: KVNamespace; + DOWNLOAD_COUNTER: DurableObjectNamespace; +}> = async ({ request, env }) => { + try { + const url = new URL(request.url); + const cursor = url.searchParams.get('cursor') || undefined; + + const { imagesKey } = (await env.IMAGES.get('setup', 'json')) as Setup; + + const kvImagesList = await env.IMAGES.list({ + prefix: IMAGE_KEY_PREFIX, + limit: 20, + cursor, + }); + + const images = ( + await Promise.all( + kvImagesList.keys.map(async kvImage => { + try { + const { id, previewURLBase, name, alt, uploaded, isPrivate, downloadCounterId } = + kvImage.metadata as ImageMetadata; + + const previewURL = isPrivate + ? `${previewURLBase}/blurred` + : generateSignedURL({ + url: `${previewURLBase}/preview`, + imagesKey, + }); + + const downloadCounter = env.DOWNLOAD_COUNTER.get( + env.DOWNLOAD_COUNTER.idFromString(downloadCounterId) + ); + // This isn't a real internet request, so the host is irrelevant (https://developers.cloudflare.com/workers/platform/compatibility-dates#durable-object-stubfetch-requires-a-full-url). + const downloadCountResponse = await downloadCounter.fetch('https://images.pages.dev/'); + const downloadCount = await downloadCountResponse.json(); + + return { + id, + previewURL, + name, + alt, + uploaded, + isPrivate, + downloadCount, + }; + } catch {} + }) + ) + ).filter(image => image !== undefined); + + return jsonResponse({ images, cursor: kvImagesList.cursor }); + } catch { + return jsonResponse({ error: 'Could not list images.' }); + } +}; diff --git a/pages-image-sharing/functions/api/setup.ts b/pages-image-sharing/functions/api/setup.ts new file mode 100644 index 00000000..2e24d84f --- /dev/null +++ b/pages-image-sharing/functions/api/setup.ts @@ -0,0 +1,11 @@ +import { jsonResponse } from '../utils/jsonResponse'; + +// Returns `true` if setup has been completed. +export const onRequestGet: PagesFunction<{ IMAGES: KVNamespace }> = async ({ env }) => { + try { + const setup = (await env.IMAGES.get('setup', 'json')) as Setup; + if (setup.imagesKey) return jsonResponse(true); + } catch {} + + return jsonResponse(false); +}; diff --git a/pages-image-sharing/functions/types.d.ts b/pages-image-sharing/functions/types.d.ts new file mode 100644 index 00000000..fe010d3e --- /dev/null +++ b/pages-image-sharing/functions/types.d.ts @@ -0,0 +1,44 @@ +interface Image { + id: string; + previewURL: string; + name: string; + alt: string; + uploaded: string; + isPrivate: boolean; + downloadCount: number; +} + +interface ImageMetadata { + id: string; + previewURLBase: string; + name: string; + alt: string; + uploaded: string; + isPrivate: boolean; + downloadCounterId: string; +} + +interface Setup { + apiToken: string; + accountId: string; + imagesKey: string; +} + +/*** Will be in @cloudflare/workers-types shortly ***/ + +type Params

= Record; + +type EventContext = { + request: Request; + waitUntil: (promise: Promise) => void; + next: (input?: RequestInfo, init?: RequestInit) => Promise; + env: Env; + params: Params

; + data: Data; +}; + +declare type PagesFunction< + Env = unknown, + Params extends string = any, + Data extends Record = Record +> = (context: EventContext) => Response | Promise; diff --git a/pages-image-sharing/functions/utils/constants.ts b/pages-image-sharing/functions/utils/constants.ts new file mode 100644 index 00000000..03c8bc23 --- /dev/null +++ b/pages-image-sharing/functions/utils/constants.ts @@ -0,0 +1 @@ +export const IMAGE_KEY_PREFIX = `image:`; diff --git a/pages-image-sharing/functions/utils/generateSignedURL.ts b/pages-image-sharing/functions/utils/generateSignedURL.ts new file mode 100644 index 00000000..831615fc --- /dev/null +++ b/pages-image-sharing/functions/utils/generateSignedURL.ts @@ -0,0 +1,43 @@ +// Adapted from https://developers.cloudflare.com/images/cloudflare-images/serve-images/serve-private-images-using-signed-url-tokens + +const bufferToHex = buffer => + [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join(''); + +const EXPIRATION = 60 * 60; // 1 hour + +export const generateSignedURL = async ({ + url: urlString, + imagesKey, +}: { + url: string; + imagesKey: string; +}) => { + const url = new URL(urlString); + const encoder = new TextEncoder(); + const secretKeyData = encoder.encode(imagesKey); + const key = await crypto.subtle.importKey( + 'raw', + secretKeyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + // Attach the expiration value to the `url` + const expiry = Math.floor(Date.now() / 1000) + EXPIRATION; + url.searchParams.set('exp', expiry.toString()); + // `url` now looks like + // https://imagedelivery.net/cheeW4oKsx5ljh8e8BoL2A/bc27a117-9509-446b-8c69-c81bfeac0a01/mobile?exp=1631289275 + + const stringToSign = url.pathname + '?' + url.searchParams.toString(); + // e.g. /cheeW4oKsx5ljh8e8BoL2A/bc27a117-9509-446b-8c69-c81bfeac0a01/mobile?exp=1631289275 + + // Generate the signature + const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(stringToSign)); + const sig = bufferToHex(new Uint8Array(mac).buffer); + + // And attach it to the `url` + url.searchParams.set('sig', sig); + + return url.toString(); +}; diff --git a/pages-image-sharing/functions/utils/jsonResponse.ts b/pages-image-sharing/functions/utils/jsonResponse.ts new file mode 100644 index 00000000..cb3c6654 --- /dev/null +++ b/pages-image-sharing/functions/utils/jsonResponse.ts @@ -0,0 +1,5 @@ +export const jsonResponse = (value: any, init: ResponseInit = {}) => + new Response(JSON.stringify(value), { + headers: { 'Content-Type': 'application/json', ...init.headers }, + ...init, + }); diff --git a/pages-image-sharing/functions/utils/parseFormDataRequest.ts b/pages-image-sharing/functions/utils/parseFormDataRequest.ts new file mode 100644 index 00000000..5ee811aa --- /dev/null +++ b/pages-image-sharing/functions/utils/parseFormDataRequest.ts @@ -0,0 +1,31 @@ +import { parseMultipart } from '@ssttevee/multipart-parser'; + +const RE_MULTIPART = /^multipart\/form-data(?:;\s*boundary=(?:"((?:[^"]|\\")+)"|([^\s;]+)))$/; + +const getBoundary = (request: Request): string | undefined => { + const contentType = request.headers.get('Content-Type'); + if (!contentType) return; + + const matches = RE_MULTIPART.exec(contentType); + if (!matches) return; + + return matches[1] || matches[2]; +}; + +export const parseFormDataRequest = async (request: Request): Promise => { + const boundary = getBoundary(request); + if (!boundary || !request.body) return; + + const parts = await parseMultipart(request.body, boundary); + + const formData = new FormData(); + + for (const { name, data, filename, contentType } of parts) { + formData.append( + name, + filename ? new File([data], filename, { type: contentType }) : new TextDecoder().decode(data) + ); + } + + return formData; +}; diff --git a/pages-image-sharing/package.json b/pages-image-sharing/package.json new file mode 100644 index 00000000..851d2941 --- /dev/null +++ b/pages-image-sharing/package.json @@ -0,0 +1,60 @@ +{ + "name": "images.pages.dev", + "version": "0.1.0", + "private": true, + "license": "MIT", + "dependencies": { + "@craco/craco": "^6.4.0", + "@ssttevee/multipart-parser": "^0.1.9", + "@testing-library/jest-dom": "^5.15.0", + "@testing-library/react": "^11.2.7", + "@testing-library/user-event": "^12.8.3", + "@types/jest": "^26.0.24", + "@types/node": "^12.20.36", + "@types/react": "^17.0.34", + "@types/react-dom": "^17.0.11", + "moment": "^2.29.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-router-dom": "^6.0.1", + "react-scripts": "4.0.3", + "swr": "^1.0.1", + "typescript": "^4.4.4", + "web-vitals": "^1.1.2" + }, + "scripts": { + "postinstall": "cd durable_objects && npm install", + "start:react": "craco start", + "start": "npx wrangler@pages pages dev --kv IMAGES -- npm run start:react", + "build": "craco build", + "test": "craco test", + "eject": "craco eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@cloudflare/workers-types": "^3.1.1", + "@tailwindcss/aspect-ratio": "^0.3.0", + "@tailwindcss/forms": "^0.3.4", + "autoprefixer": "^9.8.8", + "postcss": "^7.0.39", + "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.17", + "wrangler": "^0.0.0-602af8a" + } +} diff --git a/pages-image-sharing/public/apple-touch-icon.png b/pages-image-sharing/public/apple-touch-icon.png new file mode 100644 index 00000000..25b3a788 Binary files /dev/null and b/pages-image-sharing/public/apple-touch-icon.png differ diff --git a/pages-image-sharing/public/favicon.ico b/pages-image-sharing/public/favicon.ico new file mode 100644 index 00000000..44b15385 Binary files /dev/null and b/pages-image-sharing/public/favicon.ico differ diff --git a/pages-image-sharing/public/icon-192.png b/pages-image-sharing/public/icon-192.png new file mode 100644 index 00000000..5c9269f5 Binary files /dev/null and b/pages-image-sharing/public/icon-192.png differ diff --git a/pages-image-sharing/public/icon-512.png b/pages-image-sharing/public/icon-512.png new file mode 100644 index 00000000..e318748b Binary files /dev/null and b/pages-image-sharing/public/icon-512.png differ diff --git a/pages-image-sharing/public/icon.svg b/pages-image-sharing/public/icon.svg new file mode 100644 index 00000000..3be293f8 --- /dev/null +++ b/pages-image-sharing/public/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pages-image-sharing/public/index.html b/pages-image-sharing/public/index.html new file mode 100644 index 00000000..a332f900 --- /dev/null +++ b/pages-image-sharing/public/index.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + Images sharing platform built on Cloudflare Pages + + + + +

+ + diff --git a/pages-image-sharing/public/manifest.json b/pages-image-sharing/public/manifest.json new file mode 100644 index 00000000..2fedd510 --- /dev/null +++ b/pages-image-sharing/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "Image sharing platform", + "name": "Image sharing platform built on Cloudflare Pages", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "icon-192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "icon-512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#F6821F", + "background_color": "#ffffff" +} diff --git a/pages-image-sharing/public/robots.txt b/pages-image-sharing/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/pages-image-sharing/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/pages-image-sharing/src/App.test.tsx b/pages-image-sharing/src/App.test.tsx new file mode 100644 index 00000000..b9034da7 --- /dev/null +++ b/pages-image-sharing/src/App.test.tsx @@ -0,0 +1,7 @@ +import { render } from '@testing-library/react'; +import { App } from './App'; + +test("doesn't crash", () => { + render(); + expect(2 + 2).toBe(4); +}); diff --git a/pages-image-sharing/src/App.tsx b/pages-image-sharing/src/App.tsx new file mode 100644 index 00000000..e5aeec37 --- /dev/null +++ b/pages-image-sharing/src/App.tsx @@ -0,0 +1,32 @@ +import type { FC } from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import useSWR, { SWRConfig } from 'swr'; +import { Home } from './pages/Home'; +import { Setup } from './pages/Setup'; +import { Admin } from './pages/Admin'; +import { jsonFetcher } from './utils/jsonFetcher'; + +const Router: FC = () => { + // const { data: setup, error } = useSWR("/api/setup"); + + // if (error) return
{`Error loading setup information: ${error}`}
; + const setup = true; + + return ( + + + : } /> + {/* } /> + } /> */} + + + ); +}; + +export const App: FC = () => { + return ( + + + + ); +}; diff --git a/pages-image-sharing/src/assets/create_token.png b/pages-image-sharing/src/assets/create_token.png new file mode 100644 index 00000000..9e5fe451 Binary files /dev/null and b/pages-image-sharing/src/assets/create_token.png differ diff --git a/pages-image-sharing/src/assets/create_token_button.png b/pages-image-sharing/src/assets/create_token_button.png new file mode 100644 index 00000000..c841d3ae Binary files /dev/null and b/pages-image-sharing/src/assets/create_token_button.png differ diff --git a/pages-image-sharing/src/assets/custom_token.png b/pages-image-sharing/src/assets/custom_token.png new file mode 100644 index 00000000..22797184 Binary files /dev/null and b/pages-image-sharing/src/assets/custom_token.png differ diff --git a/pages-image-sharing/src/assets/token_configuration.png b/pages-image-sharing/src/assets/token_configuration.png new file mode 100644 index 00000000..f2ce691f Binary files /dev/null and b/pages-image-sharing/src/assets/token_configuration.png differ diff --git a/pages-image-sharing/src/components/AccessConfigurator.tsx b/pages-image-sharing/src/components/AccessConfigurator.tsx new file mode 100644 index 00000000..ed327844 --- /dev/null +++ b/pages-image-sharing/src/components/AccessConfigurator.tsx @@ -0,0 +1,59 @@ +import { FC, useState } from 'react'; +import { Banner } from './Banner'; +import { Button } from './Button'; +import { ExternalLink } from './ExternalLink'; + +export const AccessConfigurator: FC<{ onComplete: () => void }> = ({ onComplete }) => { + const [accessAud, setAccessAud] = useState(''); + const [success, setSuccess] = useState(); + const [error, setError] = useState(''); + + const configureAccess = () => { + (async () => { + const response = await fetch('/admin/api/setup/access', { + method: 'POST', + body: JSON.stringify({ aud: accessAud }), + }); + const data = await response.json(); + if (data === true) { + setSuccess(true); + onComplete(); + } else { + setError(data.error); + } + })(); + }; + + return ( + <> +
+

Configure Cloudflare Access

+

+ Using{' '} + Cloudflare Access, + protect the `/admin` path of wherever you're deploying this app. Create a "Self-hosted" + application +

+
+ + + +
+ +
+ + {success ? : undefined} + {error ? : undefined} + + ); +}; diff --git a/pages-image-sharing/src/components/AccountSelector.tsx b/pages-image-sharing/src/components/AccountSelector.tsx new file mode 100644 index 00000000..eff6368a --- /dev/null +++ b/pages-image-sharing/src/components/AccountSelector.tsx @@ -0,0 +1,173 @@ +import { FC, useState, useEffect } from 'react'; +import { ExternalLink } from './ExternalLink'; +import { Banner } from './Banner'; +import createTokenButton from '../assets/create_token_button.png'; +import customToken from '../assets/custom_token.png'; +import tokenConfiguration from '../assets/token_configuration.png'; +import createToken from '../assets/create_token.png'; + +export const AccountSelector: FC<{ + onSelectAccount: (accountId: string) => void; +}> = ({ onSelectAccount }) => { + const [error, setError] = useState(''); + const [apiToken, setApiToken] = useState(''); + const [accounts, setAccounts] = useState<{ id: string; name: string }[]>([]); + const [accountId, setAccountId] = useState(''); + + useEffect(() => { + (async () => { + if (apiToken === '') return; + + const response = await fetch(`/admin/api/setup/apiToken`, { + method: 'POST', + body: JSON.stringify({ apiToken }), + }); + const data = await response.json<{ + error?: string; + accountId?: string; + accounts?: { id: string; name: string }[]; + }>(); + + if (data.accountId) { + setError(''); + setAccountId(data.accountId); + setAccounts([]); + } else if (data.accounts && data.accounts.length > 0) { + setError(''); + setAccountId(data.accounts[0].id); + setAccounts(data.accounts); + } else { + setError( + data.error || + 'An unknown error has occurred while configuring your API token. Please try again.' + ); + setAccountId(''); + setAccounts([]); + } + })(); + }, [apiToken]); + + useEffect(() => { + (async () => { + if (accountId === '') return; + + const response = await fetch('/admin/api/setup/accountId', { + method: 'POST', + body: JSON.stringify({ accountId }), + }); + const data = await response.json<{ error?: string } | true>(); + if (data === true) { + setError(''); + onSelectAccount(accountId); + } else { + setError( + data.error || + 'An unknown error has occurred while selecting your account. Please try again.' + ); + setAccountId(''); + } + })(); + }, [accountId, onSelectAccount]); + + return ( + <> +
+

Create an API Token

+
    +
  1. + Navigate to the{' '} + + API Tokens page on the Cloudflare dashboard + + . +
  2. +
  3. +
    + Click the blue "Create Token" button. + Screenshot of the blue 'Create Token' button in the Cloudflare dashboard +
    +
  4. +
  5. +
    + + Under the "Custom token" heading, click the blue "Get started" button. + + Screenshot of the blue 'Get started' button under the 'Custom token' heading in the Cloudflare dashboard +
    +
  6. +
  7. +
    + + Give the token a name, set read permissions for "Account Settings", edit permissions + for "Cloudflare Images" and "Access: Apps and Policies", and click the blue + "Continue to summary" button. + + {/* eslint-disable-next-line jsx-a11y/img-redundant-alt */} + Screenshot of the token configuration screen with the following options. Token name: 'Image Sharing Platform'; Permissions: 'Account Settings — Read', 'Account — Cloudflare Images — Edit', 'Account — Access: Apps and Policies — Edit' +
    +
  8. +
  9. +
    + Finally, click the blue "Create Token" button. + {/* eslint-disable-next-line jsx-a11y/img-redundant-alt */} + Screenshot of the blue 'Create Token' button in the Cloudflare dashboard +
    +
  10. +
+ + + + {error ? : undefined} +
+ + {accounts.length > 0 ? ( +
+

Select an account

+ +
+ ) : undefined} + + ); +}; diff --git a/pages-image-sharing/src/components/Banner.tsx b/pages-image-sharing/src/components/Banner.tsx new file mode 100644 index 00000000..83f3cd8d --- /dev/null +++ b/pages-image-sharing/src/components/Banner.tsx @@ -0,0 +1,20 @@ +import { FC } from 'react'; + +export const Banner: FC<{ + type: 'success' | 'error'; + title: string; + description?: string; +}> = ({ type, title, description }) => { + const color = + type === 'success' + ? 'bg-green-100 text-green-800' + : type === 'error' + ? 'bg-red-100 text-red-800' + : ''; + return ( +
+

{title}

+ {description &&

{description}

} +
+ ); +}; diff --git a/pages-image-sharing/src/components/Button.tsx b/pages-image-sharing/src/components/Button.tsx new file mode 100644 index 00000000..fc4887f7 --- /dev/null +++ b/pages-image-sharing/src/components/Button.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react'; + +export const Button: FC<{ type?: 'default'; onClick: () => void }> = ({ + type = 'default', + onClick, + children, +}) => { + const color = type === 'default' ? 'bg-blue-100 text-blue-800' : ''; + return ( +
+ {children} +
+ ); +}; diff --git a/pages-image-sharing/src/components/DownloadIcon.tsx b/pages-image-sharing/src/components/DownloadIcon.tsx new file mode 100644 index 00000000..3e76df8e --- /dev/null +++ b/pages-image-sharing/src/components/DownloadIcon.tsx @@ -0,0 +1,9 @@ +import type { FC } from 'react'; + +export const DownloadIcon: FC = () => { + return ( + + + + ); +}; diff --git a/pages-image-sharing/src/components/ExternalLink.tsx b/pages-image-sharing/src/components/ExternalLink.tsx new file mode 100644 index 00000000..08caa25d --- /dev/null +++ b/pages-image-sharing/src/components/ExternalLink.tsx @@ -0,0 +1,13 @@ +import type { FC } from 'react'; + +export const ExternalLink: FC<{ href: string; className?: string }> = ({ + href, + className, + children, +}) => { + return ( + + {children} + + ); +}; diff --git a/pages-image-sharing/src/components/EyeIcon.tsx b/pages-image-sharing/src/components/EyeIcon.tsx new file mode 100644 index 00000000..4695c8ef --- /dev/null +++ b/pages-image-sharing/src/components/EyeIcon.tsx @@ -0,0 +1,11 @@ +import type { FC } from 'react'; + +export const EyeIcon: FC = () => { + return ( + + + + + + ); +}; diff --git a/pages-image-sharing/src/components/Header.tsx b/pages-image-sharing/src/components/Header.tsx new file mode 100644 index 00000000..da0ba32b --- /dev/null +++ b/pages-image-sharing/src/components/Header.tsx @@ -0,0 +1,33 @@ +import { FC } from 'react'; +import { Link } from 'react-router-dom'; + +export const Header: FC<{ login?: boolean }> = ({ login = true }) => { + return ( +
+
+

+ Image sharing platform built on{' '} + + Cloudflare Pages + +

+ {login ? ( + + ) : undefined} +
+
+ ); +}; diff --git a/pages-image-sharing/src/components/ImageGrid.tsx b/pages-image-sharing/src/components/ImageGrid.tsx new file mode 100644 index 00000000..f1c16434 --- /dev/null +++ b/pages-image-sharing/src/components/ImageGrid.tsx @@ -0,0 +1,146 @@ +import type { FC } from 'react'; +import useSWR from 'swr'; +import moment from 'moment'; +import { DownloadIcon } from './DownloadIcon'; +import { LockIcon } from './LockIcon'; +import { EyeIcon } from './EyeIcon'; + +const ImageCard: FC<{ image: Image }> = ({ + image: { id, previewURL, name, alt, uploaded, isPrivate, downloadCount }, +}) => { + return ( +
+
+
+ {alt} +
+
+ {name} +

+ +

+
+
+
+
+ + + + {downloadCount} +
+ {!isPrivate ? ( + + + + ) : ( +
+ +
+ )} +
+
+ ); +}; + +export const ImageGrid: FC = () => { + // const { data, error } = useSWR<{ images: Image[] }>("/api/images"); + + // if (error || data === undefined) { + // return ( + //
+ // An unexpected error has occurred when fetching the list of images. + // Please try again. + //
+ // ); + // } + + const data = { + images: [ + { + id: '8277aeb6-f3fb-445d-43f9-ae710b3ffc00', + previewURL: + 'https://imagedelivery.net/c_kvDVNdc0jEhXS4gDzgVA/8277aeb6-f3fb-445d-43f9-ae710b3ffc00/blurred', + name: 'hannah-grace-fk4tiMlDFF0-unsplash.jpg', + alt: 'string', + uploaded: '2021-11-17T06:31:25.203Z', + isPrivate: true, + downloadCount: 2, + }, + { + id: 'e45bc50e-814f-4f2a-e6ab-d68a3f457500', + previewURL: + 'https://imagedelivery.net/c_kvDVNdc0jEhXS4gDzgVA/e45bc50e-814f-4f2a-e6ab-d68a3f457500/blurred', + name: 'parttime-portraits-atOlntWcO4k-unsplash.jpg', + alt: 'string', + uploaded: '2021-11-17T06:32:39.845Z', + isPrivate: true, + downloadCount: 4, + }, + { + id: '4f7fb54c-8469-4be1-eba1-f43f4286e800', + previewURL: + 'https://imagedelivery.net/c_kvDVNdc0jEhXS4gDzgVA/4f7fb54c-8469-4be1-eba1-f43f4286e800/blurred', + name: 'andrew-schultz-DTSDD968Mpw-unsplash.jpg', + alt: 'string', + uploaded: '2021-11-17T06:33:43.406Z', + isPrivate: true, + downloadCount: 1, + }, + { + id: '59384c25-66ac-4a0e-abf0-381b20c52a00', + previewURL: + 'https://imagedelivery.net/c_kvDVNdc0jEhXS4gDzgVA/59384c25-66ac-4a0e-abf0-381b20c52a00/blurred', + name: 'david-clarke-sVtcRzphxbk-unsplash.jpg', + alt: 'string', + uploaded: '2021-11-17T06:34:08.727Z', + isPrivate: true, + downloadCount: 1, + }, + { + id: '73d49242-64f0-4fce-c98b-5094a2ce2800', + previewURL: + 'https://imagedelivery.net/c_kvDVNdc0jEhXS4gDzgVA/73d49242-64f0-4fce-c98b-5094a2ce2800/blurred', + name: 'karsten-winegeart-Qb7D1xw28Co-unsplash.jpg', + alt: 'string', + uploaded: '2021-11-17T06:35:25.795Z', + isPrivate: true, + downloadCount: 2, + }, + { + id: '62fd1c2a-d41b-4256-fff7-8d4e855a7300', + previewURL: + 'https://imagedelivery.net/c_kvDVNdc0jEhXS4gDzgVA/62fd1c2a-d41b-4256-fff7-8d4e855a7300/blurred', + name: 'bill-stephan-9LkqymZFLrE-unsplash.jpg', + alt: 'string', + uploaded: '2021-11-17T06:59:35.151Z', + isPrivate: true, + downloadCount: 1, + }, + { + id: '91e684d1-940b-443c-b845-b67972fc9e00', + previewURL: + 'https://imagedelivery.net/c_kvDVNdc0jEhXS4gDzgVA/91e684d1-940b-443c-b845-b67972fc9e00/blurred', + name: 'karsten-winegeart-oU6KZTXhuvk-unsplash.jpg', + alt: 'string', + uploaded: '2021-11-17T06:59:37.854Z', + isPrivate: true, + downloadCount: 1, + }, + ], + }; + + return ( +
+ {data.images.map(image => ( + + ))} +
+ ); +}; diff --git a/pages-image-sharing/src/components/ImagesConfigurator.tsx b/pages-image-sharing/src/components/ImagesConfigurator.tsx new file mode 100644 index 00000000..05bd079c --- /dev/null +++ b/pages-image-sharing/src/components/ImagesConfigurator.tsx @@ -0,0 +1,40 @@ +import { FC, useState } from 'react'; +import { Banner } from './Banner'; +import { Button } from './Button'; + +export const ImagesConfigurator: FC<{ onComplete: () => void }> = ({ onComplete }) => { + const [success, setSuccess] = useState(); + const [error, setError] = useState(''); + + const configureImages = () => { + (async () => { + const response = await fetch('/admin/api/setup/images', { + method: 'POST', + }); + const data = await response.json(); + if (data === true) { + setSuccess(true); + onComplete(); + } else { + setError(data.error); + } + })(); + }; + + return ( + <> +
+

Create Cloudflare Image variants

+

+ We will automatically create three variants for you: 'preview', 'blurred', and 'highres'. +

+
+
+ +
+ + {success ? : undefined} + {error ? : undefined} + + ); +}; diff --git a/pages-image-sharing/src/components/LockIcon.tsx b/pages-image-sharing/src/components/LockIcon.tsx new file mode 100644 index 00000000..765595f9 --- /dev/null +++ b/pages-image-sharing/src/components/LockIcon.tsx @@ -0,0 +1,10 @@ +import type { FC } from 'react'; + +export const LockIcon: FC = () => { + return ( + + + + + ); +}; diff --git a/pages-image-sharing/src/index.css b/pages-image-sharing/src/index.css new file mode 100644 index 00000000..b5c61c95 --- /dev/null +++ b/pages-image-sharing/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/pages-image-sharing/src/index.tsx b/pages-image-sharing/src/index.tsx new file mode 100644 index 00000000..76199ec4 --- /dev/null +++ b/pages-image-sharing/src/index.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import './index.css'; +import { App } from './App'; +import reportWebVitals from './reportWebVitals'; + +ReactDOM.render( + + + , + document.getElementById('root') +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/pages-image-sharing/src/pages/Admin.tsx b/pages-image-sharing/src/pages/Admin.tsx new file mode 100644 index 00000000..e892ef00 --- /dev/null +++ b/pages-image-sharing/src/pages/Admin.tsx @@ -0,0 +1,21 @@ +import type { FC } from 'react'; +import { Header } from '../components/Header'; + +export const Admin: FC = () => { + return ( +
+
+
+
+ + +
+
+
+ ); +}; diff --git a/pages-image-sharing/src/pages/Home.tsx b/pages-image-sharing/src/pages/Home.tsx new file mode 100644 index 00000000..e9353c33 --- /dev/null +++ b/pages-image-sharing/src/pages/Home.tsx @@ -0,0 +1,14 @@ +import type { FC } from 'react'; +import { Header } from '../components/Header'; +import { ImageGrid } from '../components/ImageGrid'; + +export const Home: FC = () => { + return ( +
+
+
+ +
+
+ ); +}; diff --git a/pages-image-sharing/src/pages/Setup.tsx b/pages-image-sharing/src/pages/Setup.tsx new file mode 100644 index 00000000..0aec6c0b --- /dev/null +++ b/pages-image-sharing/src/pages/Setup.tsx @@ -0,0 +1,42 @@ +import { FC, useState } from 'react'; +import { Header } from '../components/Header'; +import { ExternalLink } from '../components/ExternalLink'; +import { AccountSelector } from '../components/AccountSelector'; +import { ImagesConfigurator } from '../components/ImagesConfigurator'; +import { AccessConfigurator } from '../components/AccessConfigurator'; + +export const Setup: FC = () => { + const [accountId, setAccountId] = useState(''); + const [imagesComplete, setImagesComplete] = useState(false); + + return ( +
+
+
+
+

Thanks for trying out our full-stack application built on Cloudflare Pages.

+

+ This demo app uses{' '} + + Cloudflare Images + {' '} + and{' '} + + Cloudflare Access + + , so make sure you've activated these in the Cloudflare Dashboard. +

+
+ + + {accountId !== '' ? ( + setImagesComplete(true)} /> + ) : undefined} + + {imagesComplete ? {}} /> : undefined} + +
+
+
+ ); +}; diff --git a/pages-image-sharing/src/react-app-env.d.ts b/pages-image-sharing/src/react-app-env.d.ts new file mode 100644 index 00000000..6431bc5f --- /dev/null +++ b/pages-image-sharing/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/pages-image-sharing/src/reportWebVitals.ts b/pages-image-sharing/src/reportWebVitals.ts new file mode 100644 index 00000000..6fe8badb --- /dev/null +++ b/pages-image-sharing/src/reportWebVitals.ts @@ -0,0 +1,15 @@ +import { ReportHandler } from 'web-vitals'; + +const reportWebVitals = (onPerfEntry?: ReportHandler) => { + if (onPerfEntry && onPerfEntry instanceof Function) { + import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(onPerfEntry); + getFID(onPerfEntry); + getFCP(onPerfEntry); + getLCP(onPerfEntry); + getTTFB(onPerfEntry); + }); + } +}; + +export default reportWebVitals; diff --git a/pages-image-sharing/src/setupTests.ts b/pages-image-sharing/src/setupTests.ts new file mode 100644 index 00000000..8f2609b7 --- /dev/null +++ b/pages-image-sharing/src/setupTests.ts @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; diff --git a/pages-image-sharing/src/utils/jsonFetcher.ts b/pages-image-sharing/src/utils/jsonFetcher.ts new file mode 100644 index 00000000..49727f3a --- /dev/null +++ b/pages-image-sharing/src/utils/jsonFetcher.ts @@ -0,0 +1,2 @@ +export const jsonFetcher = (...args: Parameters) => + fetch(...args).then(response => response.json()); diff --git a/pages-image-sharing/tailwind.config.js b/pages-image-sharing/tailwind.config.js new file mode 100644 index 00000000..accdfa32 --- /dev/null +++ b/pages-image-sharing/tailwind.config.js @@ -0,0 +1,11 @@ +module.exports = { + purge: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'], + darkMode: false, // or 'media' or 'class' + theme: { + extend: {}, + }, + variants: { + extend: {}, + }, + plugins: [require('@tailwindcss/forms'), require('@tailwindcss/aspect-ratio')], +}; diff --git a/pages-image-sharing/tsconfig.json b/pages-image-sharing/tsconfig.json new file mode 100644 index 00000000..a611a4b1 --- /dev/null +++ b/pages-image-sharing/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["dom", "dom.iterable", "esnext"], + "types": ["@cloudflare/workers-types"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src", + "functions", + "durable_objects", + "tmp/setup.ts", + "tmp/images.ts", + "tmp/api", + "tmp/types.d.ts" + ] +} diff --git a/stream/auth/stripe/README.md b/stream/auth/stripe/README.md index 7fcd8fe4..419a93bc 100644 --- a/stream/auth/stripe/README.md +++ b/stream/auth/stripe/README.md @@ -1,5 +1,7 @@ # Cloudflare Stream + Stripe +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/templates/tree/main/stream/auth/stripe) + Host and monetize **paid** video content (live or on-demand) that you fully control, on your own website, using [Cloudflare Stream](https://www.cloudflare.com/products/cloudflare-stream/), [Cloudflare Pages](https://pages.cloudflare.com/), and [Stripe Checkout](https://stripe.com/payments/checkout). Inspired by Stripe Checkout's [examples](https://github.com/stripe-samples/checkout-one-time-payments), and adapted for Cloudflare Workers.