fix(demo-service): restore admin UI, add canvas error page for 401/403/404

- admin-ui.html extracted from git history
- Caddy handle_errors serves canvas page for all HTML errors
- Error page served directly by Caddy (correct status code preserved)
- Republished hermes-stats and my-awesome-demo
- Modular file structure: _config.py, _loaders.py, admin-ui.html, error-page.html
This commit is contained in:
Junv (via Hermes)
2026-06-14 11:36:04 +10:00
parent 10a055d37c
commit a8a09c6675
5 changed files with 271 additions and 10 deletions
+10 -9
View File
@@ -1,5 +1,4 @@
# Caddy for Demo Service — internal server, TLS handled by Nginx Ingress
# Auth is handled by the Python API (calls OAuth2 Proxy for SSO validation)
:80 {
log {
@@ -7,6 +6,16 @@
format json
}
# ── Custom error pages (canvas animation, served directly by Caddy) ──
handle_errors {
@html_errors expression `{err.status_code} in [401, 403, 404]`
handle @html_errors {
root * /app/static
rewrite * /error-page.html
file_server
}
}
# Management API → Python service
handle /api/* {
reverse_proxy localhost:3000 {
@@ -29,19 +38,11 @@
reverse_proxy localhost:3000
}
# ── Security: block access to internal/hidden files ──
# Prevent leaking .draft, .bak, .meta, .git files
@blocked path_regexp \.(draft|bak)$|^\.meta|^\.git
handle @blocked {
error 404
}
# Static demo sites — public, no auth
handle_path /* {
root * /data/demos
file_server {
index index.html
hide .*
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn python-multipart httpx
COPY service.py _config.py _loaders.py error-page.html /app/
COPY service.py _config.py _loaders.py error-page.html admin-ui.html /app/
RUN mkdir -p /data/demos
+249
View File
@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Demo Manager — junv.cc</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;min-height:100vh}
.header{background:#161b22;border-bottom:1px solid #30363d;padding:16px 24px;display:flex;justify-content:space-between;align-items:center}
.header h1{font-size:20px;color:#f0f6fc}
.header .user{font-size:13px;color:#8b949e}
.container{max-width:1000px;margin:24px auto;padding:0 24px}
.section{margin-bottom:32px}
.section h2{font-size:18px;color:#f0f6fc;margin-bottom:12px}
.btn{padding:8px 16px;border-radius:6px;border:1px solid #30363d;background:#21262d;color:#c9d1d9;cursor:pointer;font-size:13px;transition:all .15s}
.btn:hover{background:#30363d}
.btn-primary{background:#1f6feb;border-color:#1f6feb;color:#fff}
.btn-primary:hover{background:#388bfd}
.btn-success{background:#238636;border-color:#238636;color:#fff}
.btn-success:hover{background:#2ea043}
.btn-warning{background:#9e6a03;border-color:#9e6a03;color:#fff}
.btn-warning:hover{background:#bb8009}
.btn-danger{background:#da3633;border-color:#da3633;color:#fff}
.btn-danger:hover{background:#f85149}
.btn-sm{padding:4px 10px;font-size:12px}
.site-card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:12px;display:flex;justify-content:space-between;align-items:center}
.site-card .info{flex:1}
.site-card .name{font-size:16px;font-weight:600;color:#58a6ff}
.site-card .name a{color:inherit;text-decoration:none}
.site-card .name a:hover{text-decoration:underline}
.site-card .desc{font-size:13px;color:#8b949e;margin-top:4px}
.site-card .meta{font-size:11px;color:#6e7681;margin-top:4px}
.site-card .actions{display:flex;gap:6px}
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
.badge-on{background:#1b3a1b;color:#3fb950}
.badge-off{background:#3a1b1b;color:#f85149}
.badge-draft{background:#3a2e1b;color:#d29922}
.badge-published{background:#1b3a1b;color:#3fb950}
.form-group{margin-bottom:12px}
.form-group label{display:block;font-size:13px;color:#8b949e;margin-bottom:4px}
.form-group input{width:100%;padding:8px 12px;border-radius:6px;border:1px solid #30363d;background:#0d1117;color:#c9d1d9;font-size:14px}
.form-group input:focus{outline:none;border-color:#1f6feb}
.form-row{display:flex;gap:12px;align-items:end}
.file-list{list-style:none}
.file-list li{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid #21262d;font-size:13px}
.file-list li:last-child{border-bottom:none}
.upload-zone{border:2px dashed #30363d;border-radius:8px;padding:24px;text-align:center;cursor:pointer;transition:all .15s}
.upload-zone:hover{border-color:#58a6ff;background:#161b22}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:100}
.modal{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:24px;max-width:560px;width:90%}
.modal h3{color:#f0f6fc;margin-bottom:16px}
.toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:8px;font-size:14px;z-index:200;animation:slideIn .3s}
.toast-success{background:#1b3a1b;border:1px solid #3fb950;color:#3fb950}
.toast-error{background:#3a1b1b;border:1px solid #f85149;color:#f85149}
@keyframes slideIn{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
.spinner{display:inline-block;width:16px;height:16px;border:2px solid #30363d;border-top-color:#58a6ff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
.help-text{font-size:12px;color:#6e7681;margin-top:4px}
</style>
</head>
<body>
<div class="header"><h1>🧪 Demo Manager</h1><div class="user" id="user-info">Loading...</div></div>
<div class="container">
<div class="section">
<h2> New Demo Site <span style="font-size:12px;font-weight:400;color:#6e7681">— drafts are hidden until published</span></h2>
<form id="create-form" class="form-row">
<div class="form-group" style="flex:2"><label>Site Name</label><input type="text" id="site-name" placeholder="my-cool-demo" pattern="[a-z0-9-]+" required></div>
<div class="form-group" style="flex:3"><label>Description (optional)</label><input type="text" id="site-desc" placeholder="A quick demo of..."></div>
<div class="form-group" style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="site-publish" style="width:auto"><label for="site-publish" style="margin:0;font-size:13px">Publish now</label></div>
<button type="submit" class="btn btn-primary" style="height:38px">Create</button>
</form>
</div>
<div class="section"><h2>📂 Sites</h2><div id="sites-list">Loading...</div></div>
</div>
<div id="file-modal" class="modal-overlay"><div class="modal" id="file-modal-content"></div></div>
<div id="preview-modal" class="modal-overlay"><div class="modal" id="preview-modal-content" style="max-width:90%;max-height:90vh;overflow:auto"></div></div>
<div id="toast-container"></div>
<script>
let currentUser='';
const API='/api/sites';
async function api(method,path,body){
const opts={method,headers:{'Content-Type':'application/json'}};
if(body)opts.body=JSON.stringify(body);
const res=await fetch(path,opts);
if(!res.ok){const err=await res.json().catch(()=>({detail:res.statusText}));throw new Error(err.detail||'Request failed');}
return res.json();
}
async function uploadFile(siteName,file,subPath){
const form=new FormData();form.append('file',file);form.append('path',subPath||'');
const res=await fetch(API+'/'+siteName+'/files',{method:'POST',body:form});
if(!res.ok){const err=await res.json().catch(()=>({detail:res.statusText}));throw new Error(err.detail||'Upload failed');}
return res.json();
}
function toast(msg,type){
const el=document.createElement('div');el.className='toast toast-'+type;el.textContent=msg;
document.getElementById('toast-container').appendChild(el);setTimeout(()=>el.remove(),3000);
}
function formatSize(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB';}
function formatDate(iso){if(!iso)return'-';return new Date(iso).toLocaleString();}
async function loadSites(){
const container=document.getElementById('sites-list');
try{
const data=await api('GET',API);currentUser=data.user;
document.getElementById('user-info').textContent='👤 '+currentUser+' (Pocket ID)';
if(!data.sites.length){container.innerHTML='<p style="color:#8b949e;text-align:center;padding:32px">No sites yet. Create one above!</p>';return;}
container.innerHTML=data.sites.map(s=>{
const isPublic=s.public;
const isEnabled=s.enabled!==false;
let statusBadge='';
if(!isEnabled)statusBadge='<span class="badge badge-off">DISABLED</span>';
else if(s.published)statusBadge='<span class="badge badge-published">🔓 PUBLISHED</span>';
else statusBadge='<span class="badge badge-draft">🔒 DRAFT</span>';
let actionBtns='';
if(!isEnabled){
actionBtns+='<button class="btn btn-sm btn-success" onclick="toggleSite(\''+s.name+'\',\'enable\')">▶ Enable</button>';
}else{
actionBtns+='<button class="btn btn-sm btn-danger" onclick="toggleSite(\''+s.name+'\',\'disable\')">⏸ Disable</button>';
if(s.published){
actionBtns+='<button class="btn btn-sm btn-warning" onclick="publishSite(\''+s.name+'\',\'unpublish\')">🔒 Unpublish</button>';
}else{
actionBtns+='<button class="btn btn-sm btn-success" onclick="publishSite(\''+s.name+'\',\'publish\')">🔓 Publish</button>';
}
}
actionBtns+='<button class="btn btn-sm" onclick="manageSite(\''+s.name+'\')">📁 Files</button>';
if(!isPublic){actionBtns+='<button class="btn btn-sm" onclick="previewSite(\''+s.name+'\')">👁 Preview</button>';}
actionBtns+='<button class="btn btn-sm btn-danger" onclick="deleteSite(\''+s.name+'\')">🗑</button>';
return '<div class="site-card"><div class="info"><div class="name">'+
(isEnabled
? '<a href="https://demo.junv.cc/'+s.name+'" target="_blank">'+s.name+'</a>'
: '<a href="#" onclick="previewSite(\''+s.name+'\');return false" style="color:#58a6ff;text-decoration:none;border-bottom:1px dashed #58a6ff" title="Disabled — click to preview">'+s.name+'</a>')+
' '+statusBadge+
'</div>'+(s.description?'<div class="desc">'+s.description+'</div>':'')+
'<div class="meta">'+s.file_count+' files · created '+formatDate(s.created_at)+'</div></div>'+
'<div class="actions">'+actionBtns+'</div></div>';
}).join('');
}catch(e){container.innerHTML='<p style="color:#f85149">'+e.message+'</p>';}
}
async function createSite(e){
e.preventDefault();
const name=document.getElementById('site-name').value.trim();
const desc=document.getElementById('site-desc').value.trim();
const publishNow=document.getElementById('site-publish').checked;
if(!name)return;
try{
await api('POST',API,{name,description:desc,published:publishNow});
document.getElementById('site-name').value='';
document.getElementById('site-desc').value='';
document.getElementById('site-publish').checked=false;
toast(publishNow?'Site "'+name+'" created and published!':'Site "'+name+'" created as draft','success');
loadSites();
}catch(e){toast(e.message,'error');}
}
async function publishSite(name,action){
try{
await api('POST',API+'/'+name+'/'+action);
toast(action==='publish'?'🔓 Published! Live at demo.junv.cc/'+name:'🔒 Unpublished — back to draft','success');
loadSites();
}catch(e){toast(e.message,'error');}
}
async function toggleSite(name,action){
try{await api('POST',API+'/'+name+'/'+action);toast('Site '+action+'d','success');loadSites();}
catch(e){toast(e.message,'error');}
}
async function deleteSite(name){
if(!confirm('Delete "'+name+'"? This cannot be undone.'))return;
try{await api('DELETE',API+'/'+name);toast('Deleted','success');loadSites();}
catch(e){toast(e.message,'error');}
}
async function previewSite(name){
const modal=document.getElementById('preview-modal');
const content=document.getElementById('preview-modal-content');
modal.style.display='flex';
content.innerHTML='<h3>👁 Preview: '+name+' <span style="font-size:12px;color:#d29922">(Draft — requires auth)</span></h3><div style="text-align:center;padding:24px"><span class="spinner"></span>Loading preview...</div>';
try{
const res=await fetch(API+'/'+name+'/preview');
if(!res.ok)throw new Error((await res.json().catch(()=>({detail:'Failed'}))).detail||'Failed');
const html=await res.text();
content.innerHTML='<h3>👁 Preview: '+name+' <span style="font-size:12px;color:#d29922">(Draft)</span></h3><div style="text-align:right;margin-bottom:8px"><button class="btn btn-sm" onclick="document.getElementById(\'preview-modal\').style.display=\'none\'">Close</button></div><iframe srcdoc="'+html.replace(/"/g,'&quot;')+'" style="width:100%;height:70vh;border:1px solid #30363d;border-radius:8px;background:#fff"></iframe>';
}catch(e){
content.innerHTML='<h3>Preview Error</h3><p style="color:#f85149">'+e.message+'</p><button class="btn btn-sm" onclick="document.getElementById(\'preview-modal\').style.display=\'none\'">Close</button>';
}
}
async function manageSite(name){
const modal=document.getElementById('file-modal');
const content=document.getElementById('file-modal-content');
modal.style.display='flex';
content.innerHTML='<h3>📁 '+name+'</h3><div style="margin-bottom:16px"><div class="upload-zone" id="upload-zone-'+name+'"><div id="upload-label-'+name+'">📤 Drop files or click to upload</div><input type="file" id="file-input-'+name+'" style="display:none" multiple><div id="upload-progress-'+name+'" style="display:none;margin-top:8px"></div></div></div><div style="margin:16px 0"><strong style="font-size:13px;color:#8b949e">Files:</strong></div><div id="files-list-'+name+'" style="max-height:300px;overflow-y:auto">Loading...</div><div style="margin-top:16px;text-align:right"><button class="btn btn-sm" onclick="document.getElementById(\'file-modal\').style.display=\'none\'">Close</button></div>';
try{
const data=await api('GET',API+'/'+name);
const fd=document.getElementById('files-list-'+name);
if(!data.files.length){fd.innerHTML='<p style="color:#8b949e">No files. Upload one above.</p>';}
else{
fd.innerHTML='<ul class="file-list">'+data.files.map(f=>'<li><span><a href="'+API+'/'+name+'/preview?file='+encodeURIComponent(f.path)+'" target="_blank" style="color:#58a6ff">'+f.path+'</a></span><span style="color:#8b949e">'+formatSize(f.size)+' <button class="btn btn-sm btn-danger" style="margin-left:8px" onclick="deleteFile(\''+name+'\',\''+f.path+'\')">×</button></span></li>').join('')+'</ul>';
}
}catch(e){document.getElementById('files-list-'+name).innerHTML='<p style="color:#f85149">'+e.message+'</p>';}
const zone=document.getElementById('upload-zone-'+name);
const input=document.getElementById('file-input-'+name);
zone.onclick=()=>input.click();
zone.ondragover=(e)=>{e.preventDefault();zone.style.borderColor='#58a6ff';};
zone.ondragleave=()=>{zone.style.borderColor='';};
zone.ondrop=async(e)=>{e.preventDefault();zone.style.borderColor='';await handleUpload(name,e.dataTransfer.files);};
input.onchange=async()=>{await handleUpload(name,input.files);input.value='';};
}
async function handleUpload(siteName,files){
const pd=document.getElementById('upload-progress-'+siteName);
const label=document.getElementById('upload-label-'+siteName);
pd.style.display='block';
for(const file of files){
pd.innerHTML='<span class="spinner"></span> Uploading '+file.name+'...';
try{await uploadFile(siteName,file,'');pd.innerHTML+=' ✅ '+file.name+'<br>';}
catch(e){pd.innerHTML+=' ❌ '+file.name+': '+e.message+'<br>';}
}
label.textContent='📤 Drop more files or click to upload';
toast('Upload complete!','success');
manageSite(siteName);
}
async function deleteFile(siteName,filePath){
if(!confirm('Delete "'+filePath+'"?'))return;
try{
const form=new FormData();form.append('path',filePath);
const res=await fetch(API+'/'+siteName+'/files',{method:'DELETE',body:form});
if(!res.ok)throw new Error((await res.json().catch(()=>({detail:'Failed'}))).detail||'Failed');
toast('Deleted '+filePath,'success');manageSite(siteName);
}catch(e){toast(e.message,'error');}
}
document.getElementById('create-form').onsubmit=createSite;
loadSites();
</script>
</body>
</html>
+6
View File
@@ -96,6 +96,9 @@ spec:
subPath: Caddyfile
- name: demo-data
mountPath: /data/demos
- name: error-page
mountPath: /app/static/error-page.html
subPath: error-page.html
resources:
requests:
memory: 32Mi
@@ -135,6 +138,9 @@ spec:
- name: demo-data
persistentVolumeClaim:
claimName: demo-sites-pvc
- name: error-page
configMap:
name: error-page-html
---
apiVersion: v1
kind: Service
+5
View File
@@ -129,6 +129,11 @@ def sync_visibility(name: str):
def health():
return {"status": "ok", "root": str(DEMO_ROOT)}
@app.get("/api/error-page", response_class=HTMLResponse)
def error_page():
"""Serve the canvas error page for any HTTP error."""
return HTMLResponse(AUTH_REQUIRED_HTML)
@app.get("/api/sites")
def list_sites(request: Request):
user = require_auth(request)