diff --git a/.dockerignore b/.dockerignore index ecf1a93..5a0d1b9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,4 @@ dist README.md .env .DS_Store +cloudflare-worker diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d6e065b --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# URL of your deployed Cloudflare Worker (no trailing slash) +PUBLIC_WORKER_URL=https://r2-upload-worker.YOUR_SUBDOMAIN.workers.dev + +# A random secret string — must match UPLOAD_SECRET set in the Worker +PUBLIC_UPLOAD_SECRET=change-me-to-something-random diff --git a/Dockerfile b/Dockerfile index 5e258fc..e10b2b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,12 @@ COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY . . + +ARG PUBLIC_WORKER_URL +ARG PUBLIC_UPLOAD_SECRET +ENV PUBLIC_WORKER_URL=$PUBLIC_WORKER_URL +ENV PUBLIC_UPLOAD_SECRET=$PUBLIC_UPLOAD_SECRET + RUN pnpm run build FROM nginx:alpine diff --git a/cloudflare-worker/package.json b/cloudflare-worker/package.json new file mode 100644 index 0000000..697f48b --- /dev/null +++ b/cloudflare-worker/package.json @@ -0,0 +1,16 @@ +{ + "name": "r2-upload-worker", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.600.0", + "@aws-sdk/s3-request-presigner": "^3.600.0" + }, + "devDependencies": { + "wrangler": "^3.60.0" + } +} diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js new file mode 100644 index 0000000..01c0c51 --- /dev/null +++ b/cloudflare-worker/src/index.js @@ -0,0 +1,73 @@ +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +function corsHeaders(origin, allowedOrigin) { + const allow = allowedOrigin || '*'; + return { + 'Access-Control-Allow-Origin': allow, + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Authorization', + }; +} + +function json(data, status, headers) { + return new Response(JSON.stringify(data), { + status: status || 200, + headers: { 'Content-Type': 'application/json', ...headers }, + }); +} + +export default { + async fetch(request, env) { + const cors = corsHeaders(request.headers.get('Origin'), env.ALLOWED_ORIGIN); + + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: cors }); + } + + const url = new URL(request.url); + + if (url.pathname !== '/presign') { + return new Response('Not found', { status: 404, headers: cors }); + } + + if (request.method !== 'GET') { + return new Response('Method not allowed', { status: 405, headers: cors }); + } + + const auth = request.headers.get('Authorization'); + if (!auth || auth !== `Bearer ${env.UPLOAD_SECRET}`) { + return json({ error: 'Unauthorized' }, 401, cors); + } + + const filename = url.searchParams.get('filename'); + if (!filename) { + return json({ error: 'Missing filename parameter' }, 400, cors); + } + + const safe = filename.replace(/[^a-zA-Z0-9.\-_]/g, '_'); + const key = `videos/${Date.now()}-${safe}`; + + const client = new S3Client({ + region: 'auto', + endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: env.R2_ACCESS_KEY_ID, + secretAccessKey: env.R2_SECRET_ACCESS_KEY, + }, + }); + + try { + const command = new PutObjectCommand({ + Bucket: env.R2_BUCKET_NAME, + Key: key, + }); + + const presignedUrl = await getSignedUrl(client, command, { expiresIn: 3600 }); + + return json({ url: presignedUrl, key }, 200, cors); + } catch (err) { + return json({ error: err.message }, 500, cors); + } + }, +}; diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml new file mode 100644 index 0000000..ba909cb --- /dev/null +++ b/cloudflare-worker/wrangler.toml @@ -0,0 +1,12 @@ +name = "r2-upload-worker" +main = "src/index.js" +compatibility_date = "2024-09-23" +compatibility_flags = ["nodejs_compat"] + +# Secrets (set via `wrangler secret put `): +# R2_ACCOUNT_ID - your Cloudflare account ID +# R2_ACCESS_KEY_ID - R2 API token access key +# R2_SECRET_ACCESS_KEY - R2 API token secret +# R2_BUCKET_NAME - your R2 bucket name +# UPLOAD_SECRET - random string, same value used in blog .env +# ALLOWED_ORIGIN - your blog's URL, e.g. https://yourdomain.com diff --git a/docker-compose.yml b/docker-compose.yml index 3be35d0..e89021b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,11 @@ services: app: - build: . + build: + context: . + args: + PUBLIC_WORKER_URL: ${PUBLIC_WORKER_URL} + PUBLIC_UPLOAD_SECRET: ${PUBLIC_UPLOAD_SECRET} restart: unless-stopped ports: - "3001:80" diff --git a/src/components/Header.astro b/src/components/Header.astro index ea7027c..41b4cb3 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -41,6 +41,14 @@ import HeaderLink from './HeaderLink.astro'; About +

+ + Upload + +

diff --git a/src/pages/upload.astro b/src/pages/upload.astro new file mode 100644 index 0000000..1149a8b --- /dev/null +++ b/src/pages/upload.astro @@ -0,0 +1,395 @@ +--- +import DefaultLayout from '../layouts/DefaultLayout.astro'; + +const WORKER_URL = import.meta.env.PUBLIC_WORKER_URL; +const UPLOAD_SECRET = import.meta.env.PUBLIC_UPLOAD_SECRET; +--- + + +
+

Upload

+ +
+

Drag & drop files here or

+ + +
+ +
+ + + +
+
+
+ + + +