├── .cargo-ok ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── jestconfig.json ├── package-lock.json ├── package.json ├── src ├── auth.ts ├── index.ts └── router.ts ├── tsconfig.json ├── webpack.config.js └── wrangler.example.toml /.cargo-ok: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yusukebe/cf-s3-uploader/6a9de8f7335c485f23a237f64dfd6511cb51173b/.cargo-ok -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | transpiled 4 | /.idea/ 5 | wrangler.toml 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "all", 5 | "tabWidth": 2, 6 | "printWidth": 80 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Yusuke Wada 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cf-s3-uploader 2 | 3 | Cloudflare Worker for uploading images to Amazon S3. 4 | 5 | ## Set up 6 | 7 | First, git clone: 8 | 9 | ```bash 10 | $ https://github.com/yusukebe/cf-s3-uploader.git 11 | $ cd cf-s3-uploader 12 | ``` 13 | 14 | Copy `wrangler.exmple.toml` to `wrangler.tmol`: 15 | 16 | ```bash 17 | $ cp wrangler.example.toml wrangler.toml 18 | ``` 19 | 20 | ## Variables 21 | 22 | ### Environment variables 23 | 24 | Enviroment variables are: 25 | 26 | - `S3_BUCKET` - Your S3 bucket name 27 | - `S3_REGION` - S3 region name 28 | 29 | To set these, `wrangler.toml` 30 | 31 | ```toml 32 | [vars] 33 | S3_BUCKET = "your_bucket_name" 34 | S3_REGION = "s3_region_name" 35 | ``` 36 | 37 | ### Secret variables 38 | 39 | Secret variables are: 40 | 41 | - `NAME` - User name of basic auth 42 | - `PASS` - User password of basic auth 43 | - `AWS_ID` - AWS access key ID 44 | - `AWS_SECRET` - AWS secret access key 45 | 46 | To set these, use `wrangler secret put` command: 47 | 48 | ```bash 49 | $ wrangler secret put NAME 50 | ``` 51 | 52 | ## Publish 53 | 54 | To publish to your Cloudflare Workers: 55 | 56 | ```bash 57 | $ wrangler publish 58 | ``` 59 | 60 | ## Endpoints 61 | 62 | ### `/upload` 63 | 64 | Header: 65 | 66 | To pass the Basic Auth, add the Base64 string of "user:pass" to `Authorization` header. 67 | 68 | ``` 69 | Authorization: Basic ... 70 | ``` 71 | 72 | Body: 73 | 74 | Value of `body` is Basic64 string of image binary. 75 | 76 | ```json 77 | { 78 | "body": "Base64 Text..." 79 | } 80 | ``` 81 | 82 | ## Use with Shortcuts 83 | 84 | Awesome!!! 85 | 86 | ![Screenshot](https://user-images.githubusercontent.com/10682/139781916-8c22a6ae-b21b-48ff-ad1c-08d396f5cdd0.gif) 87 | 88 | Setting shortcuts like this: 89 | 90 | ![Screenshot](https://s3.ap-northeast-1.amazonaws.com/yusukebe.com/images/57f68c29ea5d0af4e9480ad04e89d152.png) 91 | 92 | ## Author 93 | 94 | Yusuke Wada 95 | 96 | ## LICENSE 97 | 98 | MIT 99 | -------------------------------------------------------------------------------- /jestconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | "^.+\\.(t|j)sx?$": "ts-jest" 4 | }, 5 | "testRegex": "/test/.*\\.test\\.ts$", 6 | "collectCoverageFrom": ["src/**/*.{ts,js}"] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cf-s3-uploader", 3 | "version": "0.1.0", 4 | "description": "Cloudflare Worker for uploading images to Amazon S3", 5 | "main": "dist/worker.js", 6 | "scripts": { 7 | "build": "webpack", 8 | "format": "prettier --write '*.{json,js}' 'src/**/*.{js,ts}' 'test/**/*.{js,ts}'", 9 | "lint": "eslint --max-warnings=0 src && prettier --check '*.{json,js}' 'src/**/*.{js,ts}' 'test/**/*.{js,ts}'", 10 | "test": "jest --config jestconfig.json --verbose" 11 | }, 12 | "author": "Yusuke Wada", 13 | "license": "MIT", 14 | "eslintConfig": { 15 | "root": true, 16 | "extends": [ 17 | "typescript", 18 | "prettier" 19 | ] 20 | }, 21 | "devDependencies": { 22 | "@cloudflare/workers-types": "^3.0.0", 23 | "@types/jest": "^26.0.23", 24 | "@types/service-worker-mock": "^2.0.1", 25 | "@typescript-eslint/eslint-plugin": "^4.16.1", 26 | "@typescript-eslint/parser": "^4.16.1", 27 | "eslint": "^7.21.0", 28 | "eslint-config-prettier": "^8.1.0", 29 | "eslint-config-typescript": "^3.0.0", 30 | "jest": "^27.0.1", 31 | "prettier": "^2.3.0", 32 | "service-worker-mock": "^2.0.5", 33 | "ts-jest": "^27.0.1", 34 | "ts-loader": "^9.2.2", 35 | "typescript": "^4.3.2", 36 | "webpack": "^5.38.1", 37 | "webpack-cli": "^4.7.0" 38 | }, 39 | "dependencies": { 40 | "@cloudflare/kv-asset-handler": "^0.1.3", 41 | "aws4fetch": "^1.0.13", 42 | "buffer": "^6.0.3", 43 | "itty-router": "^2.4.4" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/dommmel/cloudflare-workers-basic-auth/blob/master/index.js 2 | 3 | const CREDENTIALS_REGEXP = 4 | /^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9._~+/-]+=*) *$/ 5 | 6 | /** 7 | * RegExp for basic auth user/pass 8 | * 9 | * user-pass = userid ":" password 10 | * userid = * 11 | * password = *TEXT 12 | */ 13 | 14 | const USER_PASS_REGEXP = /^([^:]*):(.*)$/ 15 | 16 | /** 17 | * Object to represent user credentials. 18 | */ 19 | 20 | interface Credentials { 21 | name: string 22 | pass: string 23 | } 24 | 25 | /** 26 | * Parse basic auth to object. 27 | */ 28 | 29 | const parseAuthHeader = function (string: string): Credentials | undefined { 30 | // parse header 31 | const match = CREDENTIALS_REGEXP.exec(string) 32 | 33 | if (!match) { 34 | return undefined 35 | } 36 | 37 | // decode user pass 38 | const userPass = USER_PASS_REGEXP.exec(atob(match[1])) 39 | 40 | if (!userPass) { 41 | return undefined 42 | } 43 | 44 | // return credentials object 45 | return { name: userPass[1], pass: userPass[2] } 46 | } 47 | 48 | const unauthorizedResponse = function (body: string): Response { 49 | return new Response(body, { 50 | status: 401, 51 | headers: { 52 | 'WWW-Authenticate': 'Basic realm="User Visible Realm"', 53 | }, 54 | }) 55 | } 56 | 57 | export { parseAuthHeader, unauthorizedResponse } 58 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { getAssetFromKV } from '@cloudflare/kv-asset-handler' 2 | import { router } from './router' 3 | import { parseAuthHeader, unauthorizedResponse } from './auth' 4 | 5 | declare let NAME: string 6 | declare let PASS: string 7 | 8 | const handleEvent = async (event: FetchEvent): Promise => { 9 | const request = event.request 10 | 11 | const credentials = parseAuthHeader( 12 | request.headers.get('Authorization') || '', 13 | ) 14 | if (!credentials || credentials.name !== NAME || credentials.pass !== PASS) { 15 | return unauthorizedResponse('Unauthorized') 16 | } 17 | 18 | const requestUrl = new URL(request.url) 19 | if ( 20 | requestUrl.pathname === '/' || 21 | requestUrl.pathname === '/favicon.ico' || 22 | requestUrl.pathname.includes('static') 23 | ) { 24 | return await getAssetFromKV(event) 25 | } else { 26 | return await router.handle(request, event) 27 | } 28 | } 29 | addEventListener('fetch', (event: FetchEvent) => { 30 | event.respondWith(handleEvent(event)) 31 | }) 32 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'itty-router' 2 | import { AwsClient } from 'aws4fetch' 3 | import { Buffer } from 'buffer' 4 | 5 | declare let AWS_ID: string 6 | declare let AWS_SECRET: string 7 | const aws = new AwsClient({ accessKeyId: AWS_ID, secretAccessKey: AWS_SECRET }) 8 | 9 | declare let S3_REGION: string 10 | declare let S3_BUCKET: string 11 | declare let S3_FOLDER: string 12 | 13 | const endpoint = 'https://s3.' + S3_REGION + '.amazonaws.com/' + S3_BUCKET + '/' 14 | 15 | const router = Router() 16 | 17 | type JSONBody = { 18 | body: string 19 | } 20 | 21 | type Type = { 22 | mimeType: string 23 | suffix: string 24 | } 25 | 26 | const signatures: { [key: string]: Type } = { 27 | R0lGODdh: { mimeType: 'image/gif', suffix: 'gif' }, 28 | R0lGODlh: { mimeType: 'image/gif', suffix: 'gif' }, 29 | iVBORw0KGgo: { mimeType: 'image/png', suffix: 'png' }, 30 | '/9j/': { mimeType: 'image/jpg', suffix: 'jpg' }, 31 | } 32 | 33 | const detectType = (b64: string) => { 34 | for (const s in signatures) { 35 | if (b64.indexOf(s) === 0) { 36 | return signatures[s] 37 | } 38 | } 39 | } 40 | 41 | const createHash = async (buffer: ArrayBuffer) => { 42 | const digest = await crypto.subtle.digest( 43 | { 44 | name: 'MD5', 45 | }, 46 | buffer, 47 | ) 48 | const hashArray = Array.from(new Uint8Array(digest)) 49 | const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') 50 | return hashHex 51 | } 52 | 53 | router.post('/upload', async (request: Request) => { 54 | const json: JSONBody = await request.json() 55 | 56 | const body = Buffer.from(json.body, 'base64') 57 | const type = detectType(json.body) 58 | const filename = (await createHash(body)) + '.' + type?.suffix 59 | const uploadUrl = endpoint + S3_FOLDER + filename 60 | 61 | console.log('Try to upload to S3...') 62 | const res = await aws.fetch(uploadUrl, { 63 | body: body, 64 | method: 'PUT', 65 | headers: { 66 | 'x-amz-acl': 'public-read', 67 | 'Content-Type': type?.mimeType, 68 | }, 69 | }) 70 | 71 | console.log(res.status + ' : ' + res.statusText) 72 | 73 | return new Response( 74 | JSON.stringify({ 75 | status: res.status, 76 | url: uploadUrl, 77 | }), 78 | { 79 | headers: { 'content-type': 'application/json' }, 80 | status: 200, 81 | }, 82 | ) 83 | }) 84 | 85 | export { router } 86 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "module": "commonjs", 5 | "target": "esnext", 6 | "lib": ["esnext"], 7 | "alwaysStrict": true, 8 | "strict": true, 9 | "preserveConstEnums": true, 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "esModuleInterop": true, 13 | "types": [ 14 | "@cloudflare/workers-types", 15 | "@types/jest", 16 | "@types/service-worker-mock" 17 | ] 18 | }, 19 | "include": ["src"], 20 | "exclude": ["node_modules", "dist", "test"] 21 | } 22 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | entry: './src/index.ts', 5 | output: { 6 | filename: 'worker.js', 7 | path: path.join(__dirname, 'dist'), 8 | }, 9 | devtool: 'cheap-module-source-map', 10 | mode: 'development', 11 | resolve: { 12 | extensions: ['.ts', '.tsx', '.js'], 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.tsx?$/, 18 | loader: 'ts-loader', 19 | options: { 20 | // transpileOnly is useful to skip typescript checks occasionally: 21 | // transpileOnly: true, 22 | }, 23 | }, 24 | ], 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /wrangler.example.toml: -------------------------------------------------------------------------------- 1 | account_id = "" 2 | compatibility_date = "2021-11-01" 3 | name = "s3-uploader" 4 | route = "" 5 | type = "javascript" 6 | workers_dev = true 7 | zone_id = "" 8 | 9 | [site] 10 | bucket = "./public" 11 | entry-point = "./" 12 | 13 | [build] 14 | command = "npm install && npm run build" 15 | [build.upload] 16 | format = "service-worker" 17 | 18 | [vars] 19 | S3_BUCKET = "your_bucket_name" 20 | S3_FOLDER = "folder-name/" 21 | S3_REGION = "s3_region_name" 22 | 23 | [secrets] 24 | # NAME 25 | # PASS 26 | # AWS_ID 27 | # AWS_SECRET 28 | --------------------------------------------------------------------------------