feat: add web file manager UI and S3 catch-all route
This commit is contained in:
+246
@@ -0,0 +1,246 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TeleUploader · S3 File Manager</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #ffffff; --bg2: #f5f5f5; --text: #1a1a1a;
|
||||||
|
--text2: #666; --border: #e0e0e0; --accent: #2563eb;
|
||||||
|
--danger: #dc2626; --radius: 8px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117; --bg2: #161b22; --text: #c9d1d9;
|
||||||
|
--text2: #8b949e; --border: #30363d; --accent: #58a6ff;
|
||||||
|
--danger: #f85149;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg); color: var(--text); line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 12px 24px; background: var(--bg2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky; top: 0; z-index: 50;
|
||||||
|
}
|
||||||
|
.topbar .logo { font-weight: 700; font-size: 1.1rem; }
|
||||||
|
.topbar select, .topbar button {
|
||||||
|
padding: 6px 12px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); background: var(--bg);
|
||||||
|
color: var(--text); font-size: 0.875rem; cursor: pointer;
|
||||||
|
}
|
||||||
|
.topbar button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.topbar .spacer { flex: 1; }
|
||||||
|
.topbar .search input {
|
||||||
|
padding: 6px 12px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); background: var(--bg);
|
||||||
|
color: var(--text); font-size: 0.875rem; width: 200px;
|
||||||
|
}
|
||||||
|
.file-list { padding: 16px 24px; }
|
||||||
|
.breadcrumb {
|
||||||
|
padding: 8px 0; margin-bottom: 8px; font-size: 0.9rem;
|
||||||
|
color: var(--accent); cursor: pointer;
|
||||||
|
}
|
||||||
|
.breadcrumb span:hover { text-decoration: underline; }
|
||||||
|
.breadcrumb .sep { color: var(--text2); margin: 0 4px; }
|
||||||
|
.file-row {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 12px; border-radius: var(--radius);
|
||||||
|
cursor: pointer; transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.file-row:hover { background: var(--bg2); }
|
||||||
|
.file-row .icon { font-size: 1.2rem; width: 28px; text-align: center; flex-shrink: 0; }
|
||||||
|
.file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.file-row .size { width: 80px; text-align: right; color: var(--text2); font-size: 0.85rem; }
|
||||||
|
.file-row .date { width: 140px; color: var(--text2); font-size: 0.85rem; }
|
||||||
|
.file-row .actions { display: flex; gap: 4px; }
|
||||||
|
.file-row .actions button {
|
||||||
|
padding: 4px 8px; border: none; border-radius: 4px;
|
||||||
|
background: transparent; color: var(--text2); cursor: pointer; font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.file-row .actions button:hover { color: var(--text); background: var(--border); }
|
||||||
|
.dropzone {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0;
|
||||||
|
padding: 12px 24px; background: var(--bg2);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: center; color: var(--text2); font-size: 0.85rem; cursor: pointer;
|
||||||
|
}
|
||||||
|
.dropzone.dragover { background: var(--accent); color: #fff; }
|
||||||
|
.progress-overlay {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5); display: flex;
|
||||||
|
align-items: center; justify-content: center; z-index: 100;
|
||||||
|
}
|
||||||
|
.progress-card {
|
||||||
|
background: var(--bg); padding: 24px; border-radius: var(--radius);
|
||||||
|
min-width: 300px; max-width: 500px;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px; background: var(--border); border-radius: 4px;
|
||||||
|
margin: 12px 0; overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-bar .fill {
|
||||||
|
height: 100%; background: var(--accent);
|
||||||
|
transition: width 0.2s; width: 0%;
|
||||||
|
}
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5); display: flex;
|
||||||
|
align-items: center; justify-content: center; z-index: 100;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--bg); padding: 24px; border-radius: var(--radius);
|
||||||
|
min-width: 360px; max-width: 500px;
|
||||||
|
}
|
||||||
|
.modal h3 { margin-bottom: 16px; }
|
||||||
|
.modal input {
|
||||||
|
width: 100%; padding: 8px 12px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); background: var(--bg);
|
||||||
|
color: var(--text); margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.modal .buttons { display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.modal .buttons button {
|
||||||
|
padding: 8px 16px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); background: var(--bg); color: var(--text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.modal .buttons .primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.modal .buttons .danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||||
|
.empty { text-align: center; padding: 48px 24px; color: var(--text2); }
|
||||||
|
.empty h2 { font-size: 1.2rem; margin-bottom: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar">
|
||||||
|
<span class="logo">📦 TeleUploader</span>
|
||||||
|
<select id="bucketSelect" onchange="switchBucket(this.value)">
|
||||||
|
<option value="">— Select bucket —</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="showCreateBucketModal()">+ New</button>
|
||||||
|
<button onclick="showCredentialsModal()" title="S3 Credentials">🔑</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<div class="search">
|
||||||
|
<input id="searchInput" type="text" placeholder="Filter prefix..." oninput="debouncedSearch()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="breadcrumb" class="breadcrumb" style="display:none;padding:8px 24px"></div>
|
||||||
|
<div id="fileList" class="file-list">
|
||||||
|
<div class="empty"><h2>Select a bucket to get started</h2><p>Choose a bucket from the dropdown above, or create a new one.</p></div>
|
||||||
|
</div>
|
||||||
|
<div id="dropzone" class="dropzone" style="display:none">📁 Drop files here or click to upload</div>
|
||||||
|
<div id="progressOverlay" class="progress-overlay" style="display:none">
|
||||||
|
<div class="progress-card">
|
||||||
|
<h3>Uploading...</h3>
|
||||||
|
<div id="progressFileName"></div>
|
||||||
|
<div class="progress-bar"><div id="progressFill" class="fill"></div></div>
|
||||||
|
<div id="progressPercent" style="font-size:0.85rem;color:var(--text2)">0%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="modalOverlay" class="modal-overlay" style="display:none" onclick="closeModal(event)">
|
||||||
|
<div id="modalContent" class="modal" onclick="event.stopPropagation()"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let currentBucket = null, currentPrefix = '', currentObjects = [], currentPrefixes = [], allBuckets = [], searchTimer = null;
|
||||||
|
const api = async (path, opts = {}) => {
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
if (!res.ok) { const body = await res.json().catch(() => ({ error: res.statusText })); throw new Error(body.error || res.statusText); }
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
const apiJson = async (path, opts = {}) => { const res = await api(path, { headers: { 'content-type': 'application/json' }, ...opts }); return res.json(); };
|
||||||
|
const loadBuckets = async () => {
|
||||||
|
const data = await apiJson('/api/v1/buckets');
|
||||||
|
allBuckets = data.buckets || [];
|
||||||
|
const sel = document.getElementById('bucketSelect');
|
||||||
|
sel.innerHTML = '<option value="">— Select bucket —</option>' + allBuckets.map(b => `<option value="${b.name}">${b.name} (${b.objectCount})</option>`).join('');
|
||||||
|
if (currentBucket) sel.value = currentBucket;
|
||||||
|
};
|
||||||
|
const switchBucket = async (name) => {
|
||||||
|
currentBucket = name || null; currentPrefix = '';
|
||||||
|
if (name) { await loadObjects(); document.getElementById('dropzone').style.display = 'block'; }
|
||||||
|
else {
|
||||||
|
document.getElementById('fileList').innerHTML = '<div class="empty"><h2>Select a bucket</h2><p>Choose a bucket from the dropdown above.</p></div>';
|
||||||
|
document.getElementById('breadcrumb').style.display = 'none'; document.getElementById('dropzone').style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const renderBreadcrumb = () => {
|
||||||
|
const bc = document.getElementById('breadcrumb');
|
||||||
|
if (!currentPrefix) { bc.style.display = 'none'; return; }
|
||||||
|
bc.style.display = 'block';
|
||||||
|
const parts = currentPrefix.split('/').filter(Boolean);
|
||||||
|
bc.innerHTML = `<span onclick="navigateTo('')">${currentBucket}</span>`;
|
||||||
|
let accumulated = '';
|
||||||
|
for (const part of parts) { accumulated += part + '/'; bc.innerHTML += `<span class="sep">/</span><span onclick="navigateTo('${accumulated}')">${part}</span>`; }
|
||||||
|
};
|
||||||
|
const navigateTo = (prefix) => { currentPrefix = prefix; loadObjects(); };
|
||||||
|
const loadObjects = async () => {
|
||||||
|
if (!currentBucket) return;
|
||||||
|
const searchVal = document.getElementById('searchInput').value;
|
||||||
|
const prefix = searchVal || currentPrefix;
|
||||||
|
const url = `/api/v1/buckets/${encodeURIComponent(currentBucket)}/objects?prefix=${encodeURIComponent(prefix)}&delimiter=/&max-keys=200`;
|
||||||
|
try {
|
||||||
|
const data = await apiJson(url);
|
||||||
|
currentObjects = data.objects || []; currentPrefixes = data.prefixes || [];
|
||||||
|
renderFileList(); renderBreadcrumb();
|
||||||
|
} catch (e) { document.getElementById('fileList').innerHTML = `<div class="empty"><h2>Error</h2><p>${e.message}</p></div>`; }
|
||||||
|
};
|
||||||
|
const renderFileList = () => {
|
||||||
|
const container = document.getElementById('fileList');
|
||||||
|
if (currentPrefixes.length === 0 && currentObjects.length === 0) { container.innerHTML = '<div class="empty"><h2>This bucket is empty</h2><p>Drop files here to upload.</p></div>'; return; }
|
||||||
|
let html = '';
|
||||||
|
for (const prefix of currentPrefixes) {
|
||||||
|
const displayName = prefix.replace(currentPrefix, '');
|
||||||
|
html += `<div class="file-row" onclick="navigateTo('${prefix}')"><span class="icon">🗂</span><span class="name">${displayName.endsWith('/') ? displayName : displayName + '/'}</span><span class="size">—</span><span class="date"></span><span class="actions"></span></div>`;
|
||||||
|
}
|
||||||
|
for (const obj of currentObjects) {
|
||||||
|
const displayName = obj.key.replace(currentPrefix, '');
|
||||||
|
html += `<div class="file-row"><span class="icon">📄</span><span class="name">${escapeHtml(displayName)}</span><span class="size">${formatSize(obj.sizeBytes)}</span><span class="date">${formatDate(obj.lastModified)}</span><span class="actions"><button onclick="event.stopPropagation();downloadObject('${obj.key}')" title="Download">⬇</button><button onclick="event.stopPropagation();copyLink('${obj.key}')" title="Copy link">🔗</button><button onclick="event.stopPropagation();deleteObject('${obj.key}')" title="Delete">🗑</button></span></div>`;
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
};
|
||||||
|
const formatSize = (bytes) => { if (!bytes) return '0 B'; const u = ['B','KB','MB','GB','TB']; let i=0,s=bytes; while(s>=1024&&i<u.length-1){s/=1024;i++} return `${s.toFixed(i>0?1:0)} ${u[i]}`; };
|
||||||
|
const formatDate = (iso) => { if(!iso)return ''; return new Date(iso).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}); };
|
||||||
|
const escapeHtml = (s) => { const d=document.createElement('div');d.textContent=s;return d.innerHTML; };
|
||||||
|
const debouncedSearch = () => { clearTimeout(searchTimer); searchTimer = setTimeout(loadObjects, 300); };
|
||||||
|
const downloadObject = async (key) => { window.open(`/api/v1/buckets/${encodeURIComponent(currentBucket)}/download/${encodeURIComponent(key)}`,'_blank'); };
|
||||||
|
const copyLink = (key) => { navigator.clipboard.writeText(`${window.location.origin}/api/v1/buckets/${encodeURIComponent(currentBucket)}/download/${encodeURIComponent(key)}`).catch(()=>{}); };
|
||||||
|
const deleteObject = async (key) => {
|
||||||
|
if(!confirm(`Delete "${key}"?`))return;
|
||||||
|
try{await api(`/api/v1/buckets/${encodeURIComponent(currentBucket)}/${encodeURIComponent(key)}`,{method:'DELETE'});await loadObjects();}
|
||||||
|
catch(e){alert(`Delete failed: ${e.message}`);}
|
||||||
|
};
|
||||||
|
const uploadFiles = async (files) => {
|
||||||
|
if(!currentBucket||files.length===0)return;
|
||||||
|
const overlay=document.getElementById('progressOverlay'), fill=document.getElementById('progressFill'), pn=document.getElementById('progressFileName'), pp=document.getElementById('progressPercent');
|
||||||
|
overlay.style.display='flex';
|
||||||
|
for(let i=0;i<files.length;i++){
|
||||||
|
const file=files[i]; pn.textContent=`${i+1}/${files.length}: ${file.name}`; fill.style.width='0%'; pp.textContent='0%';
|
||||||
|
await new Promise((resolve,reject)=>{
|
||||||
|
const fd=new FormData(); fd.append('file',file); fd.append('key',currentPrefix+file.name);
|
||||||
|
const xhr=new XMLHttpRequest();
|
||||||
|
xhr.upload.onprogress=(e)=>{if(e.lengthComputable){const p=Math.round((e.loaded/e.total)*100);fill.style.width=p+'%';pp.textContent=p+'%';}};
|
||||||
|
xhr.onload=()=>{if(xhr.status>=200&&xhr.status<300)resolve();else reject(new Error(xhr.statusText));};
|
||||||
|
xhr.onerror=()=>reject(new Error('Upload failed'));
|
||||||
|
xhr.open('POST',`/api/v1/buckets/${encodeURIComponent(currentBucket)}/upload`); xhr.send(fd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
overlay.style.display='none'; await loadObjects();
|
||||||
|
};
|
||||||
|
const dropzone=document.getElementById('dropzone');
|
||||||
|
dropzone.addEventListener('dragover',e=>{e.preventDefault();dropzone.classList.add('dragover');});
|
||||||
|
dropzone.addEventListener('dragleave',()=>dropzone.classList.remove('dragover'));
|
||||||
|
dropzone.addEventListener('drop',e=>{e.preventDefault();dropzone.classList.remove('dragover');if(e.dataTransfer.files.length>0)uploadFiles(e.dataTransfer.files);});
|
||||||
|
dropzone.addEventListener('click',()=>{const i=document.createElement('input');i.type='file';i.multiple=true;i.onchange=()=>{if(i.files.length>0)uploadFiles(i.files);};i.click();});
|
||||||
|
const showModal=(html)=>{document.getElementById('modalContent').innerHTML=html;document.getElementById('modalOverlay').style.display='flex';};
|
||||||
|
const closeModal=(e)=>{if(e&&e.target!==e.currentTarget)return;document.getElementById('modalOverlay').style.display='none';};
|
||||||
|
const showCreateBucketModal=()=>{showModal(`<h3>Create Bucket</h3><input id="bucketNameInput" type="text" placeholder="my-bucket-name" pattern="[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]"><p style="font-size:0.8rem;color:var(--text2);margin-bottom:12px">Lowercase, 3-63 chars, no underscores</p><div class="buttons"><button onclick="closeModal()">Cancel</button><button class="primary" onclick="createBucket()">Create</button></div>`);setTimeout(()=>document.getElementById('bucketNameInput')?.focus(),100);};
|
||||||
|
const createBucket=async()=>{const n=document.getElementById('bucketNameInput').value.trim();if(!n)return;try{await apiJson('/api/v1/buckets',{method:'POST',body:JSON.stringify({name:n})});closeModal();await loadBuckets();document.getElementById('bucketSelect').value=n;await switchBucket(n);}catch(e){alert(`Failed: ${e.message}`);}};
|
||||||
|
const showCredentialsModal=()=>{showModal(`<h3>S3 Credentials</h3><p style="margin-bottom:12px;font-size:0.85rem;color:var(--text2)">Use these in any S3 client (aws-cli, rclone, s3cmd, etc.)</p><label style="font-size:0.85rem;font-weight:600">Endpoint URL</label><input type="text" value="${window.location.origin}" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Region</label><input type="text" value="us-east-1" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Access Key</label><input id="s3AccessKey" type="text" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Secret Key</label><input id="s3SecretKey" type="password" readonly onclick="this.select()"><div class="buttons"><button onclick="closeModal()">Close</button></div>`);};
|
||||||
|
loadBuckets();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,6 +5,10 @@ import { handleFileInfo, handleFileRedirect } from './routes/files';
|
|||||||
import { handleHealth } from './routes/health';
|
import { handleHealth } from './routes/health';
|
||||||
import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger';
|
import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger';
|
||||||
import { handleUpload } from './routes/upload';
|
import { handleUpload } from './routes/upload';
|
||||||
|
import { handleHome } from './routes/home';
|
||||||
|
import { handleWebApiV1 } from './routes/web-api';
|
||||||
|
import { handleS3Request } from './routes/s3';
|
||||||
|
import { isS3Request } from './utils/s3/auth';
|
||||||
import { fileInfoCache } from './utils/cache';
|
import { fileInfoCache } from './utils/cache';
|
||||||
import logger from './utils/logger';
|
import logger from './utils/logger';
|
||||||
import { metricsCollector } from './utils/metrics';
|
import { metricsCollector } from './utils/metrics';
|
||||||
@@ -31,6 +35,22 @@ const server = serve({
|
|||||||
'/swagger.json': {
|
'/swagger.json': {
|
||||||
GET: handleSwaggerJson,
|
GET: handleSwaggerJson,
|
||||||
},
|
},
|
||||||
|
'/': {
|
||||||
|
GET: handleHome,
|
||||||
|
},
|
||||||
|
'/api/v1/*': {
|
||||||
|
GET: handleWebApiV1,
|
||||||
|
POST: handleWebApiV1,
|
||||||
|
DELETE: handleWebApiV1,
|
||||||
|
PUT: handleWebApiV1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fetch: async (req: Request) => {
|
||||||
|
const headers = Object.fromEntries(req.headers);
|
||||||
|
if (isS3Request(headers)) {
|
||||||
|
return handleS3Request(req);
|
||||||
|
}
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const handleHome = async (): Promise<Response> => {
|
||||||
|
const html = await Bun.file('src/home.html').text();
|
||||||
|
return new Response(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/html; charset=utf-8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user