add visitor upload for certain people only
This commit is contained in:
parent
87985b93bf
commit
2e04516a4b
@ -5,3 +5,4 @@ dist
|
||||
README.md
|
||||
.env
|
||||
.DS_Store
|
||||
cloudflare-worker
|
||||
|
||||
5
.env.example
Normal file
5
.env.example
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
16
cloudflare-worker/package.json
Normal file
16
cloudflare-worker/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
73
cloudflare-worker/src/index.js
Normal file
73
cloudflare-worker/src/index.js
Normal file
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
12
cloudflare-worker/wrangler.toml
Normal file
12
cloudflare-worker/wrangler.toml
Normal file
@ -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 <NAME>`):
|
||||
# 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
|
||||
@ -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"
|
||||
|
||||
@ -41,6 +41,14 @@ import HeaderLink from './HeaderLink.astro';
|
||||
About
|
||||
</Link>
|
||||
</h4>
|
||||
<h4>
|
||||
<HeaderLink
|
||||
href="/upload"
|
||||
class="LinkText"
|
||||
>
|
||||
Upload
|
||||
</HeaderLink>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
395
src/pages/upload.astro
Normal file
395
src/pages/upload.astro
Normal file
@ -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;
|
||||
---
|
||||
|
||||
<DefaultLayout>
|
||||
<section>
|
||||
<h4>Upload</h4>
|
||||
|
||||
<div id="drop-zone" class="drop-zone">
|
||||
<p class="drop-hint">Drag & drop files here or</p>
|
||||
<label for="file-input" class="file-label">Choose files</label>
|
||||
<input id="file-input" type="file" accept="video/*,image/*" multiple class="file-input" />
|
||||
</div>
|
||||
|
||||
<div id="preview-grid" class="preview-grid"></div>
|
||||
|
||||
<button id="upload-btn" class="upload-btn" disabled>Upload all</button>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
</section>
|
||||
</DefaultLayout>
|
||||
|
||||
<script define:vars={{ WORKER_URL, UPLOAD_SECRET }}>
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const previewGrid = document.getElementById('preview-grid');
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
let selectedFiles = [];
|
||||
|
||||
function setStatus(msg, type) {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'status ' + (type || '');
|
||||
}
|
||||
|
||||
function addFiles(incoming) {
|
||||
const valid = Array.from(incoming).filter(f =>
|
||||
f.type.startsWith('video/') || f.type.startsWith('image/')
|
||||
);
|
||||
|
||||
if (valid.length !== incoming.length) {
|
||||
setStatus('Some files were skipped — only videos and images are accepted.', 'error');
|
||||
} else {
|
||||
setStatus('', '');
|
||||
}
|
||||
|
||||
for (const file of valid) {
|
||||
if (selectedFiles.find(f => f.name === file.name && f.size === file.size)) continue;
|
||||
selectedFiles.push(file);
|
||||
renderPreview(file);
|
||||
}
|
||||
|
||||
uploadBtn.disabled = selectedFiles.length === 0;
|
||||
}
|
||||
|
||||
function renderPreview(file) {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const card = document.createElement('div');
|
||||
card.className = 'preview-card';
|
||||
card.dataset.name = file.name;
|
||||
|
||||
let media;
|
||||
if (file.type.startsWith('video/')) {
|
||||
media = document.createElement('video');
|
||||
media.src = objectUrl;
|
||||
media.controls = true;
|
||||
media.muted = true;
|
||||
media.playsInline = true;
|
||||
} else {
|
||||
media = document.createElement('img');
|
||||
media.src = objectUrl;
|
||||
media.alt = file.name;
|
||||
}
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'preview-info';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'preview-name';
|
||||
name.textContent = file.name;
|
||||
|
||||
const size = document.createElement('span');
|
||||
size.className = 'preview-size';
|
||||
size.textContent = formatSize(file.size);
|
||||
|
||||
const progress = document.createElement('div');
|
||||
progress.className = 'preview-progress hidden';
|
||||
progress.innerHTML = `<div class="preview-bar"></div><span class="preview-pct">0%</span>`;
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'preview-remove';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.addEventListener('click', () => {
|
||||
selectedFiles = selectedFiles.filter(f => f !== file);
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
card.remove();
|
||||
uploadBtn.disabled = selectedFiles.length === 0;
|
||||
});
|
||||
|
||||
info.append(name, size, progress);
|
||||
card.append(media, info, removeBtn);
|
||||
previewGrid.appendChild(card);
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function setCardProgress(filename, pct, done, error) {
|
||||
const card = previewGrid.querySelector(`[data-name="${CSS.escape(filename)}"]`);
|
||||
if (!card) return;
|
||||
const wrap = card.querySelector('.preview-progress');
|
||||
const bar = card.querySelector('.preview-bar');
|
||||
const pctEl = card.querySelector('.preview-pct');
|
||||
wrap.classList.remove('hidden');
|
||||
bar.style.width = pct + '%';
|
||||
pctEl.textContent = pct + '%';
|
||||
if (done) card.classList.add('done');
|
||||
if (error) card.classList.add('errored');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', (e) => addFiles(e.target.files));
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('drag-over');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('drag-over');
|
||||
addFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', async () => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
uploadBtn.disabled = true;
|
||||
setStatus(`Uploading ${selectedFiles.length} file(s)…`, 'info');
|
||||
|
||||
const results = await Promise.allSettled(selectedFiles.map(uploadFile));
|
||||
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
if (failed === 0) {
|
||||
setStatus(`All ${selectedFiles.length} file(s) uploaded successfully.`, 'success');
|
||||
} else {
|
||||
setStatus(`${failed} file(s) failed. Check the previews.`, 'error');
|
||||
}
|
||||
|
||||
uploadBtn.disabled = false;
|
||||
});
|
||||
|
||||
async function uploadFile(file) {
|
||||
const res = await fetch(
|
||||
`${WORKER_URL}/presign?filename=${encodeURIComponent(file.name)}`,
|
||||
{ headers: { Authorization: `Bearer ${UPLOAD_SECRET}` } }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setCardProgress(file.name, 0, false, true);
|
||||
throw new Error(body.error || `Worker responded ${res.status}`);
|
||||
}
|
||||
|
||||
const { url } = await res.json();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', url);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
setCardProgress(file.name, Math.round((e.loaded / e.total) * 100), false, false);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
setCardProgress(file.name, 100, true, false);
|
||||
resolve();
|
||||
} else {
|
||||
setCardProgress(file.name, 0, false, true);
|
||||
reject(new Error(`R2 upload failed: ${xhr.status}`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
setCardProgress(file.name, 0, false, true);
|
||||
reject(new Error('Network error'));
|
||||
});
|
||||
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
section {
|
||||
color: #eeeeee;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.15rem;
|
||||
margin: 0 0 1.2em 0;
|
||||
color: #eeeeee;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed #444;
|
||||
border-radius: 6px;
|
||||
padding: 2em 1.5em;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.drop-zone.drag-over {
|
||||
border-color: #4ecca3;
|
||||
background: rgba(78, 204, 163, 0.05);
|
||||
}
|
||||
|
||||
.drop-hint {
|
||||
margin: 0 0 0.6em 0;
|
||||
color: #aaa;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-label {
|
||||
display: inline-block;
|
||||
padding: 0.4em 1em;
|
||||
background: transparent;
|
||||
border: 1px solid #4ecca3;
|
||||
color: #4ecca3;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.file-label:hover {
|
||||
background: #4ecca3;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Preview grid */
|
||||
|
||||
.preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
position: relative;
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.preview-card.done {
|
||||
border-color: #4ecca3;
|
||||
}
|
||||
|
||||
.preview-card.errored {
|
||||
border-color: #e05c5c;
|
||||
}
|
||||
|
||||
.preview-card video,
|
||||
.preview-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
padding: 0.5em 0.6em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2em;
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
font-size: 0.75rem;
|
||||
color: #ccc;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preview-size {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.preview-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
.preview-progress.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: #4ecca3;
|
||||
border-radius: 2px;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.preview-pct {
|
||||
font-size: 0.65rem;
|
||||
color: #aaa;
|
||||
min-width: 3ch;
|
||||
}
|
||||
|
||||
.preview-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.preview-card:hover .preview-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Upload button */
|
||||
|
||||
.upload-btn {
|
||||
display: block;
|
||||
padding: 0.5em 1.6em;
|
||||
background: #4ecca3;
|
||||
color: #1a1a2e;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.upload-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.upload-btn:not(:disabled):hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.9rem;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.status.info { color: #aaa; }
|
||||
.status.success { color: #4ecca3; }
|
||||
.status.error { color: #e05c5c; }
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user