add visitor upload for certain people only

This commit is contained in:
goro 2026-05-05 17:05:48 +03:00
parent 87985b93bf
commit 2e04516a4b
9 changed files with 521 additions and 1 deletions

View File

@ -5,3 +5,4 @@ dist
README.md
.env
.DS_Store
cloudflare-worker

5
.env.example Normal file
View 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

View File

@ -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

View 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"
}
}

View 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);
}
},
};

View 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

View File

@ -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"

View File

@ -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
View 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>