CUBE⊞ (Squared Plus): final two-CUBE architecture + worker control panel + continuity

Consolidates the working system after the broken-session recovery:

- Rename system to CUBE⊞ / Squared Plus across the console (index.html)
  and the worker control panel (cube-vm.html).
- Worker control panel (html/cube-vm.html): fix delete-alert-after-null bug;
  full save/load/delete loop verified live against /api/cube-vm/*.
- Canonical schematic + DUAL-CUBE-WORKING-PLAN doc: documents the FINAL
  architecture -- workhorse cubed = persistent substrate, VM cube =
  in-memory OS runtime, bridge (:9231) between them; two distinct builds,
  two distinct use cases, complementary by design (not a ladder).
- Deploy artifacts preserved: html/chat/* (society-relay: cubelinux.com.conf,
  relay.py, experts/models.json, nginx + systemd units), html/ingest/*,
  html/files/knowledge/* (CUBELinux spec + motion + hermes-verify docs).
- .gitignore: exclude sibling CUBELinux-2 repo, ingest venv, pycache,
  one-off export blobs (target/ already ignored).
- Coordinated with cube-schematic.py / project-continuity.py (in
  ~/.cubelinux-agent) and the project-continuity systemd timer.

Authored under user dictate to preserve all working code from the
recovered session chain.
This commit is contained in:
luulu
2026-08-15 23:20:59 -04:00
parent d489c8c44f
commit bd950ca8ce
30 changed files with 2669 additions and 426 deletions
+11
View File
@@ -4,3 +4,14 @@ bench-data/
# CUBE store data files (created by bench/test runs)
*.store
# Sibling immutable testbed repo — version-controlled on its own, do not embed.
CUBELinux-2/
# Python venv + caches (ingest worker)
html/ingest/env/
__pycache__/
*.pyc
# Uploaded binary blobs / exports (not source)
html/files/export_*/
+8 -3
View File
@@ -315,9 +315,14 @@ impl<T: Store, C: Curve> Daemon<T, C> {
/// Handle one client connection: read newline-delimited JSON requests,
/// write one JSON response per line.
fn handle_conn(&self, stream: UnixStream) -> std::io::Result<()> {
let peer = stream.try_clone()?;
let mut reader = BufReader::new(stream);
let mut writer = BufWriter::new(peer);
// One fd per connection: UnixStream is full-duplex, so read and write
// through the SAME socket instead of try_clone()-ing a second fd.
// try_clone() previously doubled fds (1 conn = 2 fds), which leaked to
// EMFILE (1024) under load and wedged the whole daemon.
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
stream.set_write_timeout(Some(Duration::from_secs(30)))?;
let mut reader = BufReader::new(&stream);
let mut writer = BufWriter::new(&stream);
let mut line = String::new();
loop {
line.clear();
+65
View File
@@ -0,0 +1,65 @@
# Mobile-only UA map (http-level, must sit outside server{})
map $http_user_agent $is_mobile {
default 0;
~*(android|iphone|ipad|ipod|mobile|windows\ phone|blackberry|webos|opera\ mini|crios|fxios) 1;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name cubelinux.com www.cubelinux.com;
root /home/cubelinux/html;
index index.html;
access_log /var/log/nginx/cubelinux.com.access.log;
error_log /var/log/nginx/cubelinux.com.error.log;
client_max_body_size 64M;
ssl_certificate /etc/letsencrypt/live/cubelinux.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/cubelinux.com/privkey.pem; # managed by Certbot
# --- CUBELinux society relay (2026-08-12) ---
location /api/ {
proxy_pass http://127.0.0.1:9001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# Mobile-only gate for the chat SPA (directory form; serves chat/index.html).
location /chat/ {
alias /home/cubelinux/html/chat/;
index index.html;
if ($is_mobile = 0) {
return 403 "Desktop access is disabled. Open this link on a phone.";
}
}
# --- end society relay ---
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass php-fpm;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
server {
if ($host = www.cubelinux.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
if ($host = cubelinux.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name cubelinux.com www.cubelinux.com;
return 301 https://$host$request_uri;
}
+34
View File
@@ -0,0 +1,34 @@
[
{
"id": "ops-nginx",
"name": "NGINX Ops",
"domain": "ops",
"prompt": "You are an NGINX configuration specialist. Answer with concrete, correct directives. Prefer the deployment's real upstream over generic examples.",
"knowledge": "This host's backend listens on 10.0.0.5:9000. Example: location /api/ { proxy_pass http://10.0.0.5:9000; }",
"user_created": false
},
{
"id": "dev-rust",
"name": "Rust Dev",
"domain": "dev",
"prompt": "You are a Rust systems-development specialist. Show idiomatic, correct code and explain trade-offs briefly.",
"knowledge": "Prefer thiserror for typed error enums; map I/O errors with #[from]. Return Result<T, E> for fallible lookups.",
"user_created": false
},
{
"id": "netops-dns",
"name": "DNS / NetOps",
"domain": "net",
"prompt": "You are a DNS / network-ops specialist. Be concrete and show the actual record or config.",
"knowledge": "An A record maps a name to IPv4. Example: www 3600 IN A 192.0.2.10",
"user_created": false
},
{
"id": "excel-expert",
"name": "Excel Expert",
"domain": "office",
"prompt": "You are an Excel / spreadsheet expert. Give exact formulas, functions, and step-by-step instructions for both Excel desktop and Google Sheets where they differ.",
"knowledge": "Common tasks: VLOOKUP/XLOOKUP, SUMIFS, pivot tables, conditional formatting, text-to-columns, and dynamic arrays (FILTER, UNIQUE, SORT). Prefer XLOOKUP over VLOOKUP for new work.",
"user_created": false
}
]
+273
View File
@@ -0,0 +1,273 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>CUBELinux Society — Local Expert Chat</title>
<style>
:root{
--gold:#d8af68; --gold-hi:#fff9b1; --cyan:#39d7ff; --bg:#0c0f14;
--panel:#141a22; --panel2:#1b232e; --ink:#e8eaeb; --muted:#8a97a6;
--ok:#5ee0a0; --line:#26303c;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{
background:radial-gradient(1200px 600px at 80% -10%, #15212e 0%, var(--bg) 55%);
color:var(--ink); font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
-webkit-tap-highlight-color:transparent;
}
.wrap{max-width:760px;margin:0 auto;padding:14px 14px 28px;display:flex;flex-direction:column;height:100dvh}
header{display:flex;align-items:center;gap:10px;padding:6px 2px 12px}
.logo{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--gold),var(--cyan));box-shadow:0 0 18px rgba(57,215,255,.35)}
h1{font-size:17px;margin:0;font-weight:700;letter-spacing:.2px}
h1 span{color:var(--cyan)}
.sub{color:var(--muted);font-size:12px;margin-top:1px}
.controls{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:10px 12px}
label{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);display:block;margin-bottom:6px}
select,input,textarea{
width:100%;background:var(--panel2);color:var(--ink);border:1px solid var(--line);
border-radius:10px;padding:9px 10px;font-size:14px;outline:none;
}
select:focus,input:focus,textarea:focus{border-color:var(--cyan)}
.experts{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
.chip{
border:1px solid var(--line);background:var(--panel);border-radius:999px;
padding:7px 13px;font-size:13px;color:var(--ink);cursor:pointer;transition:.15s;
}
.chip.on{background:linear-gradient(135deg,rgba(216,175,104,.25),rgba(57,215,255,.18));border-color:var(--gold);color:var(--gold-hi)}
.chip .x{margin-left:6px;color:var(--muted)}
.chat{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:10px;padding:6px 2px}
.msg{display:flex;gap:8px;max-width:92%}
.msg.me{align-self:flex-end;flex-direction:row-reverse}
.bubble{padding:10px 13px;border-radius:16px;white-space:pre-wrap;word-wrap:break-word}
.me .bubble{background:linear-gradient(135deg,var(--cyan),#2aa6c9);color:#04121a;border-bottom-right-radius:4px}
.bot .bubble{background:var(--panel2);border:1px solid var(--line);border-bottom-left-radius:4px}
.who{font-size:10px;color:var(--muted);margin:0 4px 2px}
.input{display:flex;gap:8px;margin-top:10px;align-items:flex-end}
textarea{resize:none;max-height:140px;min-height:44px}
button{
background:linear-gradient(135deg,var(--gold),#b8914f);color:#1a1206;border:none;border-radius:12px;
padding:0 16px;font-weight:700;font-size:15px;cursor:pointer;min-height:44px;
}
button:active{transform:translateY(1px)}
.ghost{background:transparent;color:var(--muted);border:1px solid var(--line);font-weight:600;font-size:13px;padding:0 12px;min-height:40px}
.drawer{position:fixed;inset:0;background:rgba(4,8,12,.72);backdrop-filter:blur(3px);display:none;align-items:flex-end;z-index:20}
.drawer.open{display:flex}
.sheet{background:var(--panel);width:100%;max-width:760px;margin:0 auto;border-radius:20px 20px 0 0;padding:18px 16px calc(18px + env(safe-area-inset-bottom));border-top:1px solid var(--line);max-height:90dvh;overflow:auto}
.sheet h2{margin:0 0 12px;font-size:16px}
.row{margin-bottom:11px}
.hint{color:var(--muted);font-size:12px;margin-top:5px}
.status{font-size:12px;color:var(--muted);text-align:center;margin-top:8px}
.status b{color:var(--ok)}
.badge{font-size:11px;color:var(--cyan)}
.created{font-size:11px;color:var(--gold)}
.closebar{display:flex;justify-content:flex-end;margin-bottom:6px}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo"></div>
<div>
<h1>CUBELinux <span>Society</span></h1>
<div class="sub">Local experts, no cloud — answers from your own hardware</div>
</div>
</header>
<div class="controls">
<div class="card">
<label>Model</label>
<select id="model"></select>
</div>
<div class="card">
<label>Expert</label>
<select id="expert"></select>
</div>
</div>
<div class="experts" id="expertChips"></div>
<div class="chat" id="chat"></div>
<div class="input">
<textarea id="prompt" placeholder="Ask the expert…" rows="1"></textarea>
<button id="send">Send</button>
</div>
<div style="display:flex;gap:8px;justify-content:center;margin-top:8px">
<button class="ghost" id="openCreate">+ Create expert</button>
<span class="status" id="status">connecting…</span>
</div>
</div>
<!-- Create-expert drawer -->
<div class="drawer" id="drawer">
<div class="sheet">
<div class="closebar"><button class="ghost" id="closeCreate">Done</button></div>
<h2>Create an expert</h2>
<div class="row">
<label>Name</label>
<input id="exName" placeholder="e.g. Excel Expert" />
</div>
<div class="row">
<label>Domain (short tag)</label>
<input id="exDomain" placeholder="office" />
</div>
<div class="row">
<label>Persona prompt</label>
<textarea id="exPrompt" rows="3" placeholder="You are a helpful expert in…"></textarea>
</div>
<div class="row">
<label>Knowledge / context (optional)</label>
<textarea id="exKn" rows="3" placeholder="Facts, examples, or docs this expert should know"></textarea>
<div class="hint">This is injected as the system context, so the expert answers in-character.</div>
</div>
<div class="row">
<label>Upload a knowledge document (optional)</label>
<input id="exFile" type="file" accept=".pdf,.docx,.xlsx,.pptx,.odt,.ods,.odp,.html,.htm,.rtf,.csv,.tsv,.txt,.md,.json,.xml,.yml,.yaml,.log" />
<button id="uploadDoc" class="ghost" style="width:100%;margin-top:6px">Digest &amp; add to knowledge</button>
<div class="hint">PDF / Word / Excel / PowerPoint / OpenDocument / HTML / RTF / CSV / text &mdash; text is extracted and appended to the Knowledge box above, then stored in CUBE for reuse.</div>
<div class="status" id="uploadStatus"></div>
</div>
<button id="saveExpert" style="width:100%;margin-top:4px">Save expert</button>
<div class="status" id="createStatus"></div>
</div>
</div>
<script>
const $ = (s)=>document.querySelector(s);
const api = (p)=> fetch(p,{headers:{'Accept':'application/json'}}).then(r=>r.json());
const post = (p,b)=> fetch(p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(r=>r.json());
let state = { models:[], experts:[], model:'', expert:'' };
function setStatus(t, ok){ $('#status').innerHTML = ok ? `<b>${t}</b>` : t; }
async function boot(){
try{
state.models = await api('/api/models');
state.experts = await api('/api/experts');
const h = await api('/api/health');
setStatus('connected · model '+ (h.model||''), true);
}catch(e){ setStatus('relay unreachable'); }
renderModels(); renderExperts();
if(!state.model && state.models[0]) state.model = state.models[0].id;
if(!state.expert && state.experts[0]) state.expert = state.experts[0].id;
syncSelects();
}
function renderModels(){
$('#model').innerHTML = state.models.map(m=>`<option value="${m.id}">${m.label}</option>`).join('');
}
function renderExperts(){
$('#expert').innerHTML = state.experts.map(e=>`<option value="${e.id}">${e.name}</option>`).join('');
$('#expertChips').innerHTML = state.experts.map(e=>
`<div class="chip ${e.id===state.expert?'on':''}" data-id="${e.id}">${e.name}${e.user_created?' <span class="x">✕</span>':''}</div>`
).join('');
document.querySelectorAll('#expertChips .chip').forEach(c=>{
c.onclick=(ev)=>{
if(ev.target.classList.contains('x')){
const id=c.dataset.id;
if(!confirm('Delete this expert?')) return;
fetch('/api/experts?id='+encodeURIComponent(id),{method:'DELETE'})
.then(r=>r.json()).then(d=>{
if(d.deleted){ state.experts = state.experts.filter(e=>e.id!==id); renderExperts(); }
else { alert('Could not delete: '+(d.error||'unknown')); }
}).catch(e=>alert('delete error: '+e));
return;
}
state.expert = c.dataset.id; renderExperts(); syncSelects();
};
});
}
function syncSelects(){ $('#model').value=state.model; $('#expert').value=state.expert; }
$('#model').onchange=(e)=> state.model=e.target.value;
$('#expert').onchange=(e)=> state.expert=e.target.value;
function addMsg(role, text, who){
const wrap=document.createElement('div');
wrap.className='msg '+(role==='me'?'me':'bot');
wrap.innerHTML=`<div><div class="who">${who}</div><div class="bubble"></div></div>`;
wrap.querySelector('.bubble').textContent=text;
$('#chat').appendChild(wrap);
$('#chat').scrollTop=$('#chat').scrollHeight;
}
async function send(){
const text=$('#prompt').value.trim();
if(!text) return;
const ex = state.experts.find(e=>e.id===state.expert);
addMsg('me', text, 'You');
$('#prompt').value=''; autosize();
addMsg('bot','…','thinking');
try{
const r = await post('/api/chat',{
model:state.model, expert:state.expert,
messages:[{role:'user',content:text}]
});
$('#chat').lastChild.querySelector('.bubble').textContent = r.reply || '(no reply)';
$('#chat').scrollTop=$('#chat').scrollHeight;
}catch(e){
$('#chat').lastChild.querySelector('.bubble').textContent='relay error: '+e;
}
}
$('#send').onclick=send;
$('#prompt').addEventListener('keydown',e=>{
if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); }
});
function autosize(){ const t=$('#prompt'); t.style.height='auto'; t.style.height=Math.min(t.scrollHeight,140)+'px'; }
$('#prompt').addEventListener('input',autosize);
// create drawer
$('#openCreate').onclick=()=> $('#drawer').classList.add('open');
$('#closeCreate').onclick=()=> $('#drawer').classList.remove('open');
$('#saveExpert').onclick=async()=>{
const name=$('#exName').value.trim();
if(!name){ $('#createStatus').textContent='name required'; return; }
const r= await post('/api/experts',{
name, domain:$('#exDomain').value.trim(),
prompt:$('#exPrompt').value, knowledge:$('#exKn').value
});
if(r.expert){
state.experts = await api('/api/experts');
renderExperts(); state.expert=r.expert.id; syncSelects();
$('#exName').value='';$('#exDomain').value='';$('#exPrompt').value='';$('#exKn').value='';
$('#drawer').classList.remove('open');
$('#createStatus').textContent='';
addMsg('bot','New expert "'+r.expert.name+'" ready. Pick it from the list and ask away.','society');
}else{
$('#createStatus').textContent= r.error||'save failed';
}
};
// upload document -> digest -> append to knowledge box
$('#uploadDoc').onclick = () => {
const f = $('#exFile').files && $('#exFile').files[0];
const st = $('#uploadStatus');
if (!f) { st.textContent = 'choose a file first'; return; }
st.textContent = 'digesting ' + f.name + '…';
const fd = new FormData();
fd.append('file', f);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/ingest', true);
xhr.onload = () => {
let d; try { d = JSON.parse(xhr.responseText); } catch(e) { d = {}; }
if (xhr.status >= 200 && xhr.status < 300 && d.ok) {
const cur = $('#exKn').value.trim();
const sep = cur ? '\n\n' : '';
$('#exKn').value = cur + sep + '--- from ' + (d.filename || f.name) + ' ---\n' + (d.text || '');
st.textContent = 'added ' + d.chars + ' chars from ' + (d.filename || f.name) + (d.archived ? ' (stored in CUBE)' : '');
} else {
st.textContent = 'failed: ' + (d.error || ('HTTP ' + xhr.status));
}
};
xhr.onerror = () => { st.textContent = 'network error (is the relay reachable?)'; };
xhr.send(fd);
};
boot();
</script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
[
{
"id": "LFM2.5-2.6B-Q4_K_M",
"label": "LFM2.5 2.6B (Q4)",
"desc": "Small, fast local model on the workhorse. Best for quick Q&A."
},
{
"id": "local-default",
"label": "Default local model",
"desc": "Whatever the workhorse llama-server currently serves."
}
]
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
society-relay — the Oracle-side relay for the CUBELinux "society" mobile chat.
The llama.cpp model lives on the user's workhorse (LAN-only). This relay runs
on the public Oracle box (cubelinux.com) and reaches the model through a reverse
SSH tunnel: the workhorse forwards its local :8080 to this box's :8081.
Endpoints (all JSON, CORS-open for the chat page):
GET /api/health -> {"status":"ok","model":...}
GET /api/models -> list of {id,label,desc}
GET /api/experts -> list of experts (incl. user-created)
POST /api/experts -> create an expert {name,domain,prompt,knowledge}
POST /api/chat -> {model,expert,messages} -> {reply}
Expert creation: user experts are appended to experts.json (the "create an
expert" flow). The relay injects the expert's prompt + knowledge as the system
message so the model answers in that persona. No cloud, no API key — the model
is the local llama.cpp on the workhorse.
Run as a systemd service: `society-relay.service`. Stdlib only (no pip needed).
"""
import json
import os
import sys
import urllib.request
import urllib.error
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
MODELS_PATH = os.path.join(HERE, "models.json")
EXPERTS_PATH = os.path.join(HERE, "experts.json")
UPSTREAM = "http://127.0.0.1:8081/v1/chat/completions" # <- tunnel to workhorse :8080
DEFAULT_MODEL = "LFM2.5-2.6B-Q4_K_M"
def load_json(path, default):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return default
def save_experts(data):
tmp = EXPERTS_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(tmp, EXPERTS_PATH)
def build_system(expert):
"""System prompt = persona + knowledge, so the model answers in-character."""
parts = [expert.get("prompt", "").strip()]
kn = expert.get("knowledge", "").strip()
if kn:
parts.append("KNOWLEDGE / CONTEXT (use this; do not invent outside it):")
parts.append(kn)
return "\n\n".join(p for p in parts if p)
class Handler(BaseHTTPRequestHandler):
# ---- helpers -----------------------------------------------------------
def _send(self, code, obj, ctype="application/json"):
body = json.dumps(obj).encode("utf-8") if isinstance(obj, (dict, list)) else obj
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
self.wfile.write(body)
def _recv(self):
length = int(self.headers.get("Content-Length", "0") or "0")
if length <= 0:
return {}
try:
return json.loads(self.rfile.read(length).decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return {}
def _log(self, *a): # quieter logs (not overriding BaseHTTPRequestHandler.log_message)
pass
# ---- routing -----------------------------------------------------------
def do_OPTIONS(self):
self._send(204, b"")
def do_GET(self):
if self.path == "/api/health" or self.path.startswith("/api/health?"):
models = load_json(MODELS_PATH, [])
self._send(200, {"status": "ok", "model": models[0]["id"] if models else DEFAULT_MODEL})
elif self.path == "/api/models":
self._send(200, load_json(MODELS_PATH, []))
elif self.path == "/api/experts":
self._send(200, load_json(EXPERTS_PATH, []))
else:
self._send(404, {"error": "not found"})
def do_POST(self):
if self.path == "/api/chat":
self._chat()
elif self.path == "/api/experts":
self._create_expert()
else:
self._send(404, {"error": "not found"})
# ---- endpoints ---------------------------------------------------------
def _chat(self):
req = self._recv()
messages = req.get("messages", [])
model = req.get("model") or DEFAULT_MODEL
expert = req.get("expert") # id string
# Inject the chosen expert as system persona if provided.
if expert:
for e in load_json(EXPERTS_PATH, []):
if e.get("id") == expert:
sysmsg = build_system(e)
if sysmsg:
messages = [{"role": "system", "content": sysmsg}] + messages
break
payload = {
"model": model,
"messages": messages,
"temperature": float(req.get("temperature", 0.7)),
"stream": False,
}
try:
data = json.dumps(payload).encode("utf-8")
r = urllib.request.Request(
UPSTREAM,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(r, timeout=120) as resp:
upstream = json.loads(resp.read().decode("utf-8"))
reply = upstream["choices"][0]["message"]["content"]
self._send(200, {"reply": reply})
except urllib.error.HTTPError as e:
self._send(e.code, {"error": f"upstream http {e.code}"})
except urllib.error.URLError as e:
self._send(502, {"error": f"tunnel/upstream unreachable: {e.reason}"})
except (KeyError, IndexError, ValueError) as e:
self._send(502, {"error": f"bad upstream response: {e}"})
def _create_expert(self):
req = self._recv()
name = (req.get("name") or "").strip()
if not name:
self._send(400, {"error": "name required"})
prompt = (req.get("prompt") or "").strip()
knowledge = (req.get("knowledge") or "").strip()
domain = (req.get("domain") or "usr").strip() or "usr"
# stable id: slug of name + short hash
slug = "".join(c if c.isalnum() else "-" for c in name.lower())[:32]
eid = f"usr-{slug}"
expert = {
"id": eid,
"name": name,
"domain": domain,
"prompt": prompt or f"You are {name}.",
"knowledge": knowledge,
"user_created": True,
}
experts = load_json(EXPERTS_PATH, [])
# replace if same id exists
experts = [e for e in experts if e.get("id") != eid]
experts.append(expert)
try:
save_experts(experts)
except OSError as e:
self._send(500, {"error": f"could not save: {e}"})
return
self._send(201, {"expert": expert})
def main():
port = int(os.environ.get("RELAY_PORT", "9001"))
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
print(f"society-relay listening on 127.0.0.1:{port}", flush=True)
srv.serve_forever()
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=CUBELinux society relay (Oracle side) — proxies /api to workhorse via tunnel
After=network-online.target
Wants=network-online.target
[Service]
User=opc
WorkingDirectory=/home/cubelinux/relay
Environment=RELAY_PORT=9001
ExecStart=/usr/bin/python3 /home/cubelinux/relay/relay.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+35
View File
@@ -0,0 +1,35 @@
# --- CUBELinux society relay (added 2026-08-12) ---
# Proxy /api/* to the local relay (which tunnels back to the workhorse model).
location /api/ {
proxy_pass http://127.0.0.1:9001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# Mobile-only gate for the chat UI. Desktop user-agents get 403.
# NOTE: UA is spoofable — this is a soft gate for "share with my brother",
# not real auth. Pair with an unguessable URL if you want stronger control.
map $http_user_agent $is_mobile {
default 0;
"~*(android|iphone|ipad|ipod|mobile|windows phone|blackberry|webos|opera mini|crios|fxios)" 1;
}
location = /chat/ {
# Serve the chat SPA at a clean path.
alias /home/cubelinux/html/chat/index.html;
default_type text/html;
if ($is_mobile = 0) {
return 403 'Desktop access is disabled. Open this link on a phone.';
}
}
location /chat/ {
alias /home/cubelinux/html/chat/;
if ($is_mobile = 0) {
return 403 'Desktop access is disabled. Open this link on a phone.';
}
}
# --- end society relay ---
+267
View File
@@ -0,0 +1,267 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CUBE-VM · Worker Control</title>
<style>
:root{
--gold:#d8af68; --gold-hi:#fff9b1; --cyan:#39d7ff; --bg:#0e1013;
--panel:#16191d; --panel2:#1c2026; --line:#2a2f37; --txt:#e8eaeb; --dim:#8b929c;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--txt);
font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;
align-items:center;gap:12px;background:linear-gradient(90deg,#121519,#0e1013)}
header h1{font-size:16px;margin:0;letter-spacing:.5px;color:var(--gold)}
header .sub{color:var(--dim);font-size:12px}
.wrap{display:grid;grid-template-columns:340px 1fr;gap:14px;padding:14px}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px}
.panel h2{margin:0 0 10px;font-size:13px;color:var(--cyan);letter-spacing:.4px;
text-transform:uppercase}
button{background:var(--panel2);color:var(--txt);border:1px solid var(--line);
border-radius:6px;padding:7px 11px;cursor:pointer;font:inherit}
button:hover{border-color:var(--gold)}
button.primary{background:var(--gold);color:#1a1300;border-color:var(--gold);font-weight:600}
button.danger{border-color:#a44;color:#fbb}
button.ghost{background:transparent}
input,textarea,select{background:#0c0e11;color:var(--txt);border:1px solid var(--line);
border-radius:6px;padding:7px 9px;width:100%;font:inherit}
label{display:block;margin:9px 0 3px;color:var(--dim);font-size:12px}
.row{display:flex;gap:8px;flex-wrap:wrap}
.tools{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:4px}
.tool{display:flex;align-items:center;gap:7px;background:#0c0e11;border:1px solid var(--line);
border-radius:6px;padding:5px 8px;cursor:pointer;font-size:12px}
.tool input{width:auto}
.tool.on{border-color:var(--cyan);color:var(--cyan)}
.profile{border:1px solid var(--line);border-radius:7px;padding:9px;margin-bottom:8px;
cursor:pointer;background:var(--panel2)}
.profile:hover{border-color:var(--gold)}
.profile.sel{border-color:var(--gold);box-shadow:0 0 0 1px var(--gold) inset}
.profile .nm{color:var(--gold);font-weight:600}
.profile .meta{color:var(--dim);font-size:11px;margin-top:3px}
.pill{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;
border:1px solid var(--line)}
.pill.run{color:#9f9; border-color:#3a6}
.pill.stop{color:var(--dim)}
.pill.boot{color:var(--gold-hi);border-color:var(--gold)}
.pill.fail{color:#f99;border-color:#a44}
pre{background:#0a0c0f;border:1px solid var(--line);border-radius:6px;padding:10px;
margin:0;max-height:320px;overflow:auto;color:#bfe;white-space:pre-wrap;font-size:12px}
.statusbar{display:flex;gap:14px;align-items:center;margin-bottom:10px;flex-wrap:wrap}
.hint{color:var(--dim);font-size:11px;margin-top:6px}
.tag{color:var(--cyan)}
</style>
</head>
<body>
<header>
<h1>CUBE⊞</h1>
<span class="sub">Squared Plus · Worker control · profiles stored in workhorse CUBE (ns: cube-vm) · guest boots in-memory and pulls its profile</span>
</header>
<div class="wrap">
<!-- LEFT: profile roster + editor -->
<div>
<div class="panel">
<h2>Workers</h2>
<div class="row" style="margin-bottom:8px">
<button id="newBtn">+ New</button>
<button id="saveBtn" class="primary">Save</button>
<button id="delBtn" class="danger ghost">Delete</button>
</div>
<div id="roster"></div>
</div>
</div>
<!-- RIGHT: editor + status -->
<div>
<div class="panel">
<h2>Profile Editor</h2>
<div class="row">
<div style="flex:1"><label>Profile ID</label><input id="pid" placeholder="e.g. worker-nginx-ops"></div>
<div style="flex:1"><label>Display name</label><input id="pname" placeholder="NGINX Ops Worker"></div>
</div>
<label>Tool set <span class="hint">(a worker's capabilities — each worker can differ entirely)</span></label>
<div class="tools" id="tools"></div>
<div class="row" style="margin-top:10px">
<div style="flex:1"><label>Model binding</label>
<select id="model">
<option value="local-default">local-default</option>
<option value="LFM2.5-2.6B-Q4_K_M">LFM2.5 2.6B (Q4)</option>
</select>
</div>
<div style="flex:1"><label>vCPUs</label><input id="vcpus" type="number" value="2"></div>
<div style="flex:1"><label>RAM (MB)</label><input id="mem" type="number" value="2048"></div>
</div>
<label>Expert / persona</label>
<input id="exDomain" placeholder="domain e.g. ops" style="margin-bottom:6px">
<textarea id="exPrompt" rows="2" placeholder="System prompt / persona"></textarea>
<textarea id="exKnowledge" rows="3" placeholder="Domain knowledge (recall) the worker loads on boot"></textarea>
<label>Recall coordinates <span class="hint">(specific CUBE coords/namespaces replayed into the guest in-memory store on boot — curate per worker for isolation)</span></label>
<textarea id="recall" rows="2" placeholder='{"coords":["..."],"namespaces":["hermes"]}'></textarea>
<div class="hint">JSON: {"coords":[...], "namespaces":[...]}</div>
</div>
<div class="panel" style="margin-top:14px">
<h2>Runtime</h2>
<div class="statusbar">
<span id="statePill" class="pill stop">stopped</span>
<span id="pidTxt" class="dim"></span>
<button id="launchBtn" class="primary">Launch with this profile</button>
<button id="saveBtn2">Save state</button>
<button id="saveStopBtn" class="danger ghost">Save &amp; stop</button>
<button id="killBtn" class="danger">Kill VM</button>
<button id="refreshBtn" class="ghost">Refresh</button>
</div>
<div class="hint">Autosave keeps a rolling snapshot every 15 min while running (rolling tag <span class="tag">autosave-rolling</span>). "Save state" is an on-demand snapshot without stopping; "Save &amp; stop" freezes the exact running session into the qcow2 then quits cleanly.</div>
<pre id="serial">— serial output —</pre>
<div class="hint">Launch passes the profile id to the guest boot; the guest pulls tools/expert/recall from workhorse CUBE over the host bridge.</div>
</div>
</div>
</div>
<script>
// The 10 real agent tool primitives (from crates/cube-agent/src/tools.rs)
const TOOL_PRIMS = [
"read_file","list_dir","shell_ro","write_agent_file",
"cube_write","cube_read","cube_list","web_lookup",
"run_tool","spawn"
];
let current = null; // selected profile id
function toolBox(){
const box = document.getElementById('tools'); box.innerHTML='';
TOOL_PRIMS.forEach(t=>{
const d=document.createElement('label'); d.className='tool'; d.dataset.t=t;
d.innerHTML=`<input type="checkbox"><span>${t}</span>`;
d.onclick=e=>{e.preventDefault(); d.classList.toggle('on');
const c=d.querySelector('input'); c.checked=!c.checked;};
box.appendChild(d);
});
}
function setTools(arr){
document.querySelectorAll('#tools .tool').forEach(d=>{
const on=arr.includes(d.dataset.t); d.classList.toggle('on',on);
d.querySelector('input').checked=on;
});
}
function getTools(){
return [...document.querySelectorAll('#tools .tool.on')].map(d=>d.dataset.t);
}
function fill(p){
document.getElementById('pid').value = current||'';
document.getElementById('pname').value = p.name||current||'';
document.getElementById('model').value = p.model||'local-default';
document.getElementById('vcpus').value = (p.runtime&&p.runtime.vcpus)||2;
document.getElementById('mem').value = (p.runtime&&p.runtime.mem_mb)||2048;
const ex=p.expert||{};
document.getElementById('exDomain').value=ex.domain||'';
document.getElementById('exPrompt').value=ex.prompt||'';
document.getElementById('exKnowledge').value=ex.knowledge||'';
document.getElementById('recall').value=JSON.stringify(p.recall||{coords:[],namespaces:[]});
setTools(p.tools||[]);
}
function readEditor(){
let recall={coords:[],namespaces:[]};
try{ recall=JSON.parse(document.getElementById('recall').value||'{}'); }catch(e){}
return {
name:document.getElementById('pname').value,
tools:getTools(),
model:document.getElementById('model').value,
expert:{
domain:document.getElementById('exDomain').value,
prompt:document.getElementById('exPrompt').value,
knowledge:document.getElementById('exKnowledge').value
},
recall:recall,
runtime:{vcpus:+document.getElementById('vcpus').value,
mem_mb:+document.getElementById('mem').value}
};
}
async function loadRoster(){
const r=await fetch('/api/cube-vm/configs').then(x=>x.json());
const box=document.getElementById('roster'); box.innerHTML='';
(r.profiles||[]).forEach(p=>{
const d=document.createElement('div'); d.className='profile'+(p.id===current?' sel':'');
const meta=(p.profile||{});
const tools=(meta.tools||[]).length;
d.innerHTML=`<div class="nm">${p.id}</div>
<div class="meta">${(meta.name||'')} · ${tools} tools · ${(meta.model||'')}</div>`;
d.onclick=()=>{ current=p.id; fill(meta); loadRoster(); };
box.appendChild(d);
});
}
async function save(){
const id=document.getElementById('pid').value.trim();
if(!id){alert('Set a Profile ID first');return;}
current=id;
const body={id, profile:readEditor()};
const r=await fetch('/api/cube-vm/config',{method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json();
if(j.ok){await loadRoster(); alert('Saved '+id);}
else alert('Save failed: '+(j.error||JSON.stringify(j)));
}
async function del(){
if(!current)return;
if(!confirm('Delete profile '+current+'?'))return;
const r=await fetch('/api/cube-vm/delete',{method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify({id:current})});
const j=await r.json();
if(j.ok){const gone=current; current=null; await loadRoster(); alert('Deleted '+gone);}
else alert('Delete failed: '+(j.error||JSON.stringify(j)));
}
async function launch(){
if(!current){alert('Select or save a profile first');return;}
const r=await fetch('/api/cube-vm/launch',{method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify({id:current})});
const j=await r.json();
if(j.ok){poll();} else alert('Launch failed: '+(j.error||JSON.stringify(j)));
}
async function kill(){
const r=await fetch('/api/cube-vm/kill',{method:'POST'});
poll();
}
async function saveState(){
const r=await fetch('/api/cube-vm/save-state',{method:'POST',
headers:{'Content-Type':'application/json'},body:'{}'});
const j=await r.json();
alert(j.ok?('Saved snapshot: '+(j.tag||'')):'Save failed: '+(j.error||JSON.stringify(j)));
poll();
}
async function saveAndStop(){
if(!confirm('Save the running session, then stop the VM?'))return;
const r=await fetch('/api/cube-vm/save-and-stop',{method:'POST',
headers:{'Content-Type':'application/json'},body:'{}'});
const j=await r.json();
alert(j.ok?('Saved & stopped ('+(j.tag||'')+')'):'Failed: '+(j.error||JSON.stringify(j)));
poll();
}
async function poll(){
const r=await fetch('/api/cube-vm/status').then(x=>x.json());
const pill=document.getElementById('statePill');
pill.className='pill '+(r.running?(r.boot_state==='login-ready'?'run':(r.boot_state==='boot-failed'?'fail':'boot')):'stop');
pill.textContent=r.running?r.boot_state:'stopped';
document.getElementById('pidTxt').textContent=r.running?('pid '+r.pid):'';
document.getElementById('serial').textContent=r.serial_tail||'— serial output —';
}
document.getElementById('newBtn').onclick=()=>{current=null;
fill({name:'',tools:[],expert:{},recall:{coords:[],namespaces:[]},runtime:{vcpus:2,mem_mb:2048}});
document.getElementById('pid').value='';loadRoster();};
document.getElementById('saveBtn').onclick=save;
document.getElementById('delBtn').onclick=del;
document.getElementById('launchBtn').onclick=launch;
document.getElementById('killBtn').onclick=kill;
document.getElementById('saveBtn2').onclick=saveState;
document.getElementById('saveStopBtn').onclick=saveAndStop;
document.getElementById('refreshBtn').onclick=poll;
toolBox(); loadRoster(); poll();
setInterval(poll, 4000);
</script>
</body>
</html>
Binary file not shown.
@@ -0,0 +1 @@
CUBELinux is a coordinate-addressed data store. Records live at CZYX coordinates, not file paths. The metatag vocabulary tags each record with doc_type, title, and linked_records so associations are first-class.
@@ -0,0 +1,32 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 47 >>
stream
BT /F1 18 Tf 72 700 Td (VERIFY PDF TEXT ok.) Tj ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000241 00000 n
0000000341 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
411
%%EOF
@@ -0,0 +1,32 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 90 >>
stream
BT /F1 18 Tf 72 700 Td (HERMES VERIFY. The quick brown fox jumps over the lazy dog.) Tj ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000241 00000 n
0000000381 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
451
%%EOF
@@ -0,0 +1 @@
CUBELinux addresses data by CZYX coordinate, not path. Metatagged records make linked associations first-class.
@@ -0,0 +1 @@
CUBELinux stores records at CZYX coordinates instead of file paths. Metatags make associations first-class.
@@ -0,0 +1,32 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 47 >>
stream
BT /F1 18 Tf 72 700 Td (HTTP 201 should now be accepted.) Tj ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000241 00000 n
0000000354 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
424
%%EOF
@@ -0,0 +1 @@
<html><body><h1>Title</h1><p>Body text here</p></body></html>
@@ -0,0 +1 @@
{ "a": 1, "b": [2,3] }
@@ -0,0 +1,32 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 96 >>
stream
BT /F1 18 Tf 72 700 Td (CUBE Knowledge Test. The quick brown fox jumps over the lazy dog.) Tj ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000241 00000 n
0000000387 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
457
%%EOF
+733 -423
View File
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Document text extraction for CUBE ingest.
Pure functions, no I/O side effects beyond reading the in-memory bytes.
Each extractor takes (raw_bytes) and returns a cleaned string or raises.
Supported kinds (by extension / content sniff):
pdf -> pypdf
docx -> python-docx
xlsx -> openpyxl (sheet-by-sheet, as TSV-ish text)
pptx -> python-pptx
odt/odp/ods -> odfpy
html/htm -> BeautifulSoup -> text
rtf -> striprtf
csv -> decoded text
txt/md/json/log/yml/yaml/xml/py/js/ts/css/sh/conf/ini/cfg -> utf-8 text
"""
import io
import re
# Optional imports (all installed in the venv); import lazily so a missing
# optional dep only disables that one format instead of the whole service.
try:
from pypdf import PdfReader
except Exception:
PdfReader = None
try:
from docx import Document as _docx_Document
except Exception:
_docx_Document = None
try:
import openpyxl as _openpyxl
except Exception:
_openpyxl = None
try:
from pptx import Presentation as _pptx_Presentation
except Exception:
_pptx_Presentation = None
try:
from odf.opendocument import load as _odf_load
from odf.text import P as _odf_p
from odf.table import Table as _odf_table, TableRow as _odf_row, TableCell as _odf_cell
except Exception:
_odf_load = None
try:
from bs4 import BeautifulSoup as _BeautifulSoup
except Exception:
_BeautifulSoup = None
try:
from striprtf.striprtf import rtf_to_text as _rtf2text
except Exception:
_rtf2text = None
# Extensions we accept and the extractor that handles each.
TEXT_EXTS = {"txt", "md", "markdown", "json", "log", "yml", "yaml", "xml",
"py", "js", "ts", "css", "sh", "bash", "conf", "ini", "cfg",
"toml", "csv", "tsv", "text"}
FORMAT_EXTS = {"pdf", "docx", "xlsx", "xls", "pptx", "odt", "odp", "ods",
"html", "htm", "rtf"}
ALLOWED_EXTS = TEXT_EXTS | FORMAT_EXTS
MAX_TEXT_CHARS = 200_000 # ~200k chars (~40-50 pages) is plenty for an expert persona
MAX_RAW_BYTES = 32 * 1024 * 1024 # 32 MiB ceiling (nginx also caps)
def _sniff_ext(filename: str) -> str:
name = (filename or "").lower()
if "." in name:
return name.rsplit(".", 1)[1]
return ""
def _clean(text: str) -> str:
if text is None:
return ""
# Normalize whitespace: collapse 3+ blank lines, trim trailing space.
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _pdf(raw: bytes) -> str:
if PdfReader is None:
raise RuntimeError("PDF support unavailable (pypdf missing)")
out = []
reader = PdfReader(io.BytesIO(raw))
for page in reader.pages:
try:
out.append(page.extract_text() or "")
except Exception:
pass
return _clean("\n\n".join(out))
def _docx(raw: bytes) -> str:
if _docx_Document is None:
raise RuntimeError("DOCX support unavailable (python-docx missing)")
doc = _docx_Document(io.BytesIO(raw))
parts = [p.text for p in doc.paragraphs]
for table in doc.tables:
for row in table.rows:
parts.append(" | ".join(c.text for c in row.cells))
return _clean("\n".join(p for p in parts if p))
def _xlsx(raw: bytes) -> str:
if _openpyxl is None:
raise RuntimeError("XLSX support unavailable (openpyxl missing)")
wb = _openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
blocks = []
for ws in wb.worksheets:
rows = []
for r in ws.iter_rows(values_only=True):
rows.append("\t".join("" if c is None else str(c) for c in r))
blocks.append(f"# Sheet: {ws.title}\n" + "\n".join(rows))
return _clean("\n\n".join(blocks))
def _pptx(raw: bytes) -> str:
if _pptx_Presentation is None:
raise RuntimeError("PPTX support unavailable (python-pptx missing)")
prs = _pptx_Presentation(io.BytesIO(raw))
parts = []
for slide in prs.slides:
for shape in slide.shapes:
if shape.has_text_frame:
parts.append(shape.text_frame.text)
return _clean("\n".join(p for p in parts if p))
def _odf(raw: bytes) -> str:
if _odf_load is None:
raise RuntimeError("ODF support unavailable (odfpy missing)")
doc = _odf_load(io.BytesIO(raw))
parts = []
for p in doc.getElementsByType(_odf_p):
parts.append("".join(str(n) for n in p.childNodes if n.nodeType == 3))
# also pull table cells
for tbl in doc.getElementsByType(_odf_table):
for row in tbl.getElementsByType(_odf_row):
cells = [c.firstChild.data if (c.firstChild and c.firstChild.data) else ""
for c in row.getElementsByType(_odf_cell)]
parts.append(" | ".join(cells))
return _clean("\n".join(parts))
def _html(raw: bytes) -> str:
if _BeautifulSoup is None:
# Fallback: strip tags with a regex.
return _clean(re.sub(r"<[^>]+>", " ", raw.decode("utf-8", "replace")))
soup = _BeautifulSoup(raw.decode("utf-8", "replace"), "html.parser")
for tag in soup(["script", "style"]):
tag.decompose()
return _clean(soup.get_text("\n"))
def _rtf(raw: bytes) -> str:
if _rtf2text is None:
raise RuntimeError("RTF support unavailable (striprtf missing)")
return _clean(_rtf2text(raw.decode("utf-8", "replace")))
def extract(filename: str, raw: bytes) -> str:
"""Return cleaned plain text from `raw`. Raises on unknown/unsupported."""
if len(raw) > MAX_RAW_BYTES:
raise ValueError(f"file too large ({len(raw)} bytes > {MAX_RAW_BYTES})")
ext = _sniff_ext(filename)
if ext not in ALLOWED_EXTS:
raise ValueError(f"unsupported file type: .{ext or '?'}")
if ext in TEXT_EXTS:
text = _clean(raw.decode("utf-8", "replace"))
elif ext == "pdf":
text = _pdf(raw)
elif ext == "docx":
text = _docx(raw)
elif ext in ("xlsx", "xls"):
text = _xlsx(raw)
elif ext == "pptx":
text = _pptx(raw)
elif ext in ("odt", "odp", "ods"):
text = _odf(raw)
elif ext in ("html", "htm"):
text = _html(raw)
elif ext == "rtf":
text = _rtf(raw)
else:
raise ValueError(f"unsupported file type: .{ext}")
if not text:
raise ValueError("no extractable text found (is the file empty or image-only?)")
if len(text) > MAX_TEXT_CHARS:
text = text[:MAX_TEXT_CHARS] + "\n\n[… truncated to first %d characters …]" % MAX_TEXT_CHARS
return text
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""
cube-ingest — CUBELinux document ingestion service (workhorse side).
Listens on 127.0.0.1:8083. Reached from the public Oracle relay via the
existing reverse SSH tunnel: Oracle:8083 -> workhorse:8083.
Flow:
POST /api/ingest (multipart: file) -> extract text -> store in CUBE
GET /api/ingest/health -> {ok, cubed}
Document text is extracted (PDF/DOCX/XLSX/PPTX/ODT/HTML/RTF/TXT/CSV/MD/...)
and written into the canonical CUBE store (cubed daemon, namespace "knowledge")
via cube_bridge. The returned digest reference is what the chat SPA injects into
an expert's "Knowledge / context" block.
Stdlib HTTP server + the venv's extraction libs. Run as a systemd --user unit
under luulu (same user that owns cubed), so the CUBE socket is reachable.
"""
import hashlib
import json
import os
import re
import sys
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# cube_bridge lives in the agent dir; it is the canonical CUBE adapter.
sys.path.insert(0, "/home/luulu/.cubelinux-agent")
import cube_bridge as cb
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from extract import extract, ALLOWED_EXTS, MAX_RAW_BYTES # noqa: E402
HOST = "127.0.0.1"
PORT = 8083
CUBE_NS = "knowledge" # canonical CUBE namespace for ingested knowledge
KIND = "knowledge-doc" # cube_bridge kind tag
# Files are also kept verbatim under here (nginx /files not exposed publicly;
# this is a local archive on the workhorse, useful for re-digest / audit).
ARCHIVE_DIR = "/home/CUBELinux/html/files/knowledge"
MAX_TEXT_CHARS = 200_000
def _safe(s: str) -> str:
s = re.sub(r"[^A-Za-z0-9._()-]", "_", s).strip("._-") or "doc"
return s[:120]
def _cubed_ok() -> bool:
return bool(cb._daemon_alive())
def parse_multipart(body: bytes, boundary: bytes):
"""Minimal multipart/form-data parser (stdlib only; cgi is gone in 3.13).
Returns a dict: {field_name: {"filename": str|None, "content": bytes}}.
Only the first matching field of a given name is kept (we only need 'file').
"""
out = {}
delimiter = b"--" + boundary
parts = body.split(delimiter)
for part in parts:
if not part or part in (b"--\r\n", b"--", b"\r\n", b""):
continue
# strip leading CRLF after delimiter
if part.startswith(b"\r\n"):
part = part[2:]
if part in (b"--\r\n", b"--"):
continue # closing boundary
header_end = part.find(b"\r\n\r\n")
if header_end == -1:
continue
raw_headers = part[:header_end].decode("utf-8", "replace")
content = part[header_end + 4:]
# drop trailing CRLF that precedes the delimiter
if content.endswith(b"\r\n"):
content = content[:-2]
name = filename = None
for line in raw_headers.split("\r\n"):
low = line.lower()
if low.startswith("content-disposition:"):
for tok in line.split(";"):
tok = tok.strip()
if tok.startswith("name="):
name = tok[5:].strip('"')
elif tok.startswith("filename="):
filename = tok[9:].strip('"')
if name is not None:
out[name] = {"filename": filename, "content": content}
return out
class Handler(BaseHTTPRequestHandler):
server_version = "CUBEIngest/1.0"
protocol_version = "HTTP/1.1"
def _json(self, code, obj):
body = json.dumps(obj).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
sys.stderr.write("%s %s\n" % (self.log_date_time_string(), fmt % args))
def do_OPTIONS(self):
self._json(204, b"")
def do_GET(self):
if self.path.startswith("/api/ingest/health"):
return self._json(200, {"ok": True, "cubed": _cubed_ok()})
return self._json(404, {"error": "not found"})
def do_POST(self):
if not self.path.startswith("/api/ingest"):
return self._json(404, {"error": "not found"})
if self.path != "/api/ingest":
return self._json(404, {"error": "not found"})
return self._ingest()
# ---- ingestion ---------------------------------------------------------
def _ingest(self):
ctype = self.headers.get("Content-Type", "")
if not ctype.startswith("multipart/form-data"):
return self._json(400, {"error": "expected multipart/form-data"})
# Bounded read of the raw body, then parse.
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
return self._json(400, {"error": "bad Content-Length"})
if length <= 0:
return self._json(400, {"error": "empty body"})
if length > MAX_RAW_BYTES:
return self._json(413, {"error": "payload too large"})
body = self.rfile.read(length)
# Extract the multipart boundary from Content-Type and parse (stdlib only).
m = re.search(r"boundary=([^;]+)", ctype)
if not m:
return self._json(400, {"error": "missing multipart boundary"})
boundary = m.group(1).strip().strip('"').encode("utf-8")
try:
form = parse_multipart(body, boundary)
except Exception as e:
return self._json(400, {"error": f"bad multipart body: {e}"})
item = form.get("file")
if not item or not item.get("filename"):
return self._json(400, {"error": "no file field"})
filename = os.path.basename(item["filename"])
raw = item["content"]
# 1) extract text
try:
text = extract(filename, raw)
except ValueError as e:
return self._json(415, {"error": str(e), "filename": filename})
except RuntimeError as e:
return self._json(500, {"error": str(e), "filename": filename})
except Exception as e: # never leak internals as 500 w/o message
return self._json(500, {"error": f"extraction failed: {e}", "filename": filename})
# 2) build canonical CUBE entry
stem = _safe(os.path.splitext(filename)[0])
digest = hashlib.sha1(text.encode("utf-8")).hexdigest()[:10]
rid = f"kn-{stem}-{digest}"
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
cube_value = json.dumps({
"id": rid,
"filename": filename,
"bytes": len(raw),
"chars": len(text),
"ingested_at": now,
"source": "mobile-upload",
"text": text,
}, ensure_ascii=False)
ok = cb.cube_write(CUBE_NS, rid, cube_value, kind=KIND, visibility="private")
transport = cb.get_last_transport()
if not ok:
return self._json(502, {"error": "CUBE store unreachable", "transport": transport})
# 3) optional verbatim archive on the workhorse
archived = False
try:
os.makedirs(ARCHIVE_DIR, exist_ok=True)
ap = os.path.join(ARCHIVE_DIR, f"{rid}-{_safe(filename)}")
with open(ap, "wb") as fh:
fh.write(raw)
os.chmod(ap, 0o640)
try:
os.chown(ap, 1000, 1000)
except OSError:
pass
archived = True
except Exception:
archived = False # non-fatal; CUBE already has it
self.log_message("ingested %s -> CUBE %s/%s (%d chars, archive=%s)",
filename, CUBE_NS, rid, len(text), archived)
return self._json(201, {
"ok": True,
"id": rid,
"namespace": CUBE_NS,
"filename": filename,
"chars": len(text),
"archived": archived,
"transport": transport,
# The chat SPA appends `text` straight into the Knowledge field.
"text": text,
})
def main():
os.makedirs(ARCHIVE_DIR, exist_ok=True)
srv = ThreadingHTTPServer((HOST, PORT), Handler)
srv.daemon_threads = True
sys.stderr.write(f"CUBEIngest listening on {HOST}:{PORT} (CUBE ns={CUBE_NS})\n")
sys.stderr.flush()
srv.serve_forever()
if __name__ == "__main__":
main()
+211
View File
@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
/* spec.php — public spec / updates gate page for cubelinux.com
- GET : shows the intro + registration form (same layout as index.html)
- POST: validates Name + Email, stores to MariaDB when wired,
otherwise spools to a CSV file. Renders a thank-you / error.
DB wiring: fill the $DB_* constants below once MariaDB is provisioned.
Until then, submissions land in $SPOOL (readable by you, owned by the
web user) so nothing is lost. */
$DB_HOST = 'localhost';
$DB_NAME = 'cubelinux';
$DB_USER = 'registrations';
$DB_PASS = ''; // TODO: set when MariaDB is provisioned
$DB_TABLE = 'spec_registrations';
$SPOOL = '/var/lib/cubelinux/spec-registrations.csv';
$errors = [];
$ok = false;
$name = $company = $email = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim(strip_tags((string)($_POST['name'] ?? '')));
$company = trim(strip_tags((string)($_POST['company'] ?? '')));
$email = trim(strip_tags((string)($_POST['email'] ?? '')));
$hp = (string)($_POST['website'] ?? ''); // honeypot
if ($hp !== '') { $errors[] = 'Spam check failed.'; }
if ($name === '') { $errors[] = 'Please enter your name.'; }
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Please enter a valid email address.'; }
if (!$errors) {
$log = function(string $s): void {
global $SPOOL;
@file_put_contents(dirname($SPOOL) . '/spec.log',
date('c') . ' ' . $s . "\n", FILE_APPEND);
};
$stored = false;
// DB path: only when a password has been configured, and never
// let a connection failure break the spool fallback.
if ($DB_PASS !== '' && function_exists('mysqli_connect')) {
try {
$m = mysqli_connect($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
mysqli_query($m, "CREATE TABLE IF NOT EXISTS `$DB_TABLE` (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
company VARCHAR(255),
email VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB");
$q = mysqli_query($m, sprintf(
"INSERT INTO `$DB_TABLE` (name,company,email) VALUES ('%s','%s','%s')",
mysqli_real_escape_string($m, $name),
mysqli_real_escape_string($m, $company),
mysqli_real_escape_string($m, $email)
));
$stored = (bool)$q;
mysqli_close($m);
} catch (\Throwable $e) {
$log('DB insert failed: ' . $e->getMessage());
$stored = false;
}
}
// Always spool to CSV as the durable record until DB confirmed.
$dir = dirname($SPOOL);
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
$line = sprintf("%s,%s,%s,%s\n",
str_replace([",", "\n"], " ", $name),
str_replace([",", "\n"], " ", $company),
$email, date('c'));
$wrote = @file_put_contents($SPOOL, $line, FILE_APPEND);
if ($wrote === false) {
$log('SPOOL WRITE FAILED: ' . $SPOOL);
} else {
$log('spooled: ' . $email . ($stored ? ' (also DB)' : ' (DB not configured/live)'));
}
$ok = true;
}
}
function e(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b0e14">
<title>CUBELinux — spec &amp; updates</title>
<link rel="icon" type="image/png" href="/images/favicon-64.png">
<meta name="description" content="Get CUBELinux build updates as they become available, or email us direct.">
<style>
:root{
color-scheme: dark;
--gold:#d8af68; --gold-hi:#fff9b1; --cyan:#39d7ff; --cyan-deep:#2384ba;
--ink:#e8eaeb; --mute:#8b98a5; --bg:#0b0e14; --line:rgba(216,175,104,.18);
}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--ink);
font:16px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
-webkit-font-smoothing:antialiased;}
a{color:var(--cyan)}
header{position:relative;overflow:hidden;border-bottom:1px solid var(--line);
padding:clamp(1rem,3vw,1.75rem) clamp(1rem,4vw,2.5rem);
background:
radial-gradient(60ch 30ch at 12% 30%, rgba(57,215,255,.10), transparent 70%),
radial-gradient(40ch 22ch at 6% 40%, rgba(216,175,104,.08), transparent 70%),
linear-gradient(180deg, #11151d 0%, var(--bg) 100%);}
header::before{content:"";position:absolute;inset:0;
background-image:
linear-gradient(rgba(216,175,104,.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(216,175,104,.055) 1px, transparent 1px);
background-size:44px 44px;
-webkit-mask-image:radial-gradient(80% 120% at 8% 40%, #000 0%, transparent 75%);
mask-image:radial-gradient(80% 120% at 8% 40%, #000 0%, transparent 75%);
pointer-events:none;}
.bar{position:relative;z-index:1;max-width:1100px;margin:0 auto;
display:flex;align-items:center;gap:1.25rem;flex-wrap:wrap;}
.brand{display:flex;align-items:center;text-decoration:none;flex-shrink:0;
filter:drop-shadow(0 0 18px rgba(57,215,255,.20)) drop-shadow(0 1px 2px rgba(0,0,0,.6));}
.brand img{display:block;height:clamp(38px,6vw,52px);width:auto;}
.brand:focus-visible{outline:2px solid var(--cyan);outline-offset:6px;border-radius:4px}
nav{margin-left:auto;display:flex;gap:.35rem;flex-wrap:wrap}
nav a{display:inline-flex;align-items:center;min-height:44px;padding:.5rem .9rem;
color:var(--mute);text-decoration:none;font-size:.92rem;
border:1px solid transparent;border-radius:6px;}
nav a:hover{color:var(--ink);border-color:var(--line);background:rgba(216,175,104,.05)}
main{max-width:1100px;margin:0 auto;padding:clamp(2rem,6vw,4.5rem) clamp(1rem,4vw,2.5rem)}
h1{font-size:clamp(1.9rem,5vw,3.1rem);line-height:1.12;margin:0 0 1rem;
letter-spacing:-.02em;text-wrap:balance;}
h1 em{font-style:normal;color:var(--gold)}
.lede{font-size:clamp(1.02rem,2.2vw,1.2rem);color:var(--mute);max-width:62ch;text-wrap:pretty}
.status{display:inline-flex;align-items:center;gap:.55rem;margin-bottom:1.5rem;
padding:.35rem .8rem;border:1px solid var(--line);border-radius:100px;
font-size:.78rem;letter-spacing:.1em;text-transform:uppercase;color:var(--gold)}
.status::before{content:"";width:6px;height:6px;border-radius:50%;
background:var(--cyan);box-shadow:0 0 8px var(--cyan)}
.email{color:var(--cyan)}
footer{border-top:1px solid var(--line);margin-top:4rem;
padding:1.5rem clamp(1rem,4vw,2.5rem);color:var(--mute);font-size:.85rem}
footer .in{max-width:1100px;margin:0 auto;display:flex;gap:1rem;flex-wrap:wrap}
footer a{margin-left:auto}
/* ---- registration form ---- */
.reg{max-width:62ch;margin:2rem 0 0;display:grid;gap:1.1rem}
.reg label{display:grid;gap:.4rem;font-size:.9rem;color:var(--mute)}
.reg input{width:100%;padding:.7rem .8rem;font:inherit;color:var(--ink);
background:rgba(255,255,255,.03);border:1px solid var(--line);border-radius:8px;}
.reg input:focus{outline:none;border-color:var(--cyan);box-shadow:0 0 0 3px rgba(57,215,255,.15)}
.reg button{justify-self:start;padding:.7rem 1.4rem;font:inherit;font-weight:600;cursor:pointer;
color:var(--bg);background:var(--gold);border:none;border-radius:8px;}
.reg button:hover{background:var(--gold-hi)}
.hp{position:absolute;left:-9999px}
.msg{margin-top:1.5rem;padding:1rem 1.2rem;border:1px solid var(--line);
border-radius:10px;background:rgba(57,215,255,.06);max-width:62ch}
.msg.err{background:rgba(216,175,104,.06)}
</style>
</head>
<body>
<header>
<div class="bar">
<a class="brand" href="/" aria-label="CUBELinux home">
<img src="/images/logo-header@2x.png" alt="CUBELinux">
</a>
<nav>
<a href="/spec.php">Spec &#8599;</a>
</nav>
</div>
</header>
<main>
<?php if ($ok): ?>
<div class="msg">
Thanks &mdash; you're on the list. We'll email <span class="email">info@cubelinux.com</span>
updates as they're ready. Or reach us any time at
<a class="email" href="mailto:info@cubelinux.com">info@cubelinux.com</a>.
</div>
<?php else: ?>
<span class="status">Spec &amp; updates</span>
<h1>Want to find out more?</h1>
<p class="lede">
Register to get CUBELinux build updates as they become available &mdash; or email us
direct at <a class="email" href="mailto:info@cubelinux.com">info@cubelinux.com</a>.
</p>
<?php if ($errors): ?>
<div class="msg err"><?= e(implode(' ', $errors)) ?></div>
<?php endif; ?>
<form class="reg" method="post" action="/spec.php" novalidate>
<label>Name <input name="name" required value="<?= e($name) ?>"></label>
<label>Company <input name="company" value="<?= e($company) ?>"></label>
<label>Email <input type="email" name="email" required value="<?= e($email) ?>"></label>
<input class="hp" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
<button type="submit">Register for updates</button>
</form>
<?php endif; ?>
</main>
<footer>
<div class="in">
<span>CUBELinux.com &mdash; local testbed</span>
<a href="/spec.php">Read the spec &#8599;</a>
</div>
</footer>
</body>
</html>
+255
View File
@@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b0e14">
<title>Upload — CUBELinux testbed</title>
<style>
:root{
color-scheme: dark;
--bg:#0b0e14; --panel:#0d1117; --line:#21262d;
--ink:#c9d1d9; --mute:#8b949e; --accent:#58a6ff; --ok:#7ee787;
--warn:#f0883e; --err:#f85149;
}
*{box-sizing:border-box}
body{
margin:0; background:var(--bg); color:var(--ink);
font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;
padding:max(1rem,env(safe-area-inset-top)) 1rem 3rem;
}
.wrap{max-width:760px;margin:0 auto}
header{display:flex;align-items:baseline;gap:.75rem;flex-wrap:wrap;
padding:.5rem 0 1.25rem;border-bottom:1px solid var(--line);margin-bottom:1.5rem}
h1{font-size:1.5rem;margin:0;letter-spacing:.12em;color:var(--accent)}
.sub{color:var(--mute);font-size:.85rem}
a{color:var(--accent)}
/* ---- drop zone ---- */
#drop{
border:2px dashed var(--line); border-radius:10px; background:var(--panel);
padding:2.5rem 1.5rem; text-align:center; cursor:pointer;
transition:border-color .15s, background .15s;
}
#drop:hover,#drop:focus-visible{border-color:var(--accent);outline:none}
#drop.hot{border-color:var(--ok); background:#0f1a12}
#drop .big{font-size:1.1rem;margin-bottom:.4rem}
#drop .hint{color:var(--mute);font-size:.85rem}
/* 44px+ touch target on phones */
.btn{
display:inline-block;margin-top:1rem;padding:.7rem 1.4rem;min-height:44px;
border:1px solid var(--accent);border-radius:6px;background:transparent;
color:var(--accent);font:inherit;cursor:pointer;
}
.btn:active{background:#132436}
input[type=file]{display:none}
/* ---- queue ---- */
#queue{margin-top:1.5rem;display:grid;gap:.5rem}
.row{
border:1px solid var(--line);border-radius:8px;background:var(--panel);
padding:.7rem .9rem;display:grid;gap:.45rem;
}
.row .top{display:flex;justify-content:space-between;gap:1rem;align-items:baseline}
.row .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.row .st{font-size:.8rem;color:var(--mute);flex-shrink:0}
.row.done .st{color:var(--ok)} .row.err .st{color:var(--err)}
.bar{height:4px;background:#161b22;border-radius:2px;overflow:hidden}
.bar i{display:block;height:100%;width:0;background:var(--accent);
transition:width .2s ease}
.row.done .bar i{background:var(--ok)}
.row.err .bar i{background:var(--err)}
/* ---- stored list ---- */
h2{font-size:.85rem;text-transform:uppercase;letter-spacing:.15em;
color:var(--mute);margin:2.5rem 0 .75rem;font-weight:normal}
table{width:100%;border-collapse:collapse;font-size:.88rem}
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line)}
th{color:var(--mute);font-weight:normal;font-size:.78rem}
td.sz,td.dt{color:var(--mute);white-space:nowrap}
td.ac{text-align:right}
.del{background:none;border:none;color:var(--mute);cursor:pointer;
font:inherit;padding:.35rem .5rem;min-height:34px}
.del:hover{color:var(--err)}
.empty{color:var(--mute);font-size:.85rem;padding:.75rem .5rem}
@media (max-width:480px){ td.dt,th.dt{display:none} }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>UPLOAD</h1>
<span class="sub">&rarr; /home/CUBELinux/html/files/</span>
<span class="sub" style="margin-left:auto"><a href="/">&larr; site</a></span>
</header>
<div id="drop" tabindex="0" role="button" aria-label="Choose files to upload">
<div class="big">Drop files here</div>
<div class="hint">or tap to choose &mdash; photos, video, any file</div>
<button class="btn" type="button" id="pick">Choose files</button>
<input type="file" id="file" multiple>
</div>
<div id="queue"></div>
<h2>Stored files</h2>
<table>
<thead><tr>
<th>Name</th><th class="sz">Size</th><th class="dt">Uploaded</th><th></th>
</tr></thead>
<tbody id="list"></tbody>
</table>
<div class="empty" id="empty" hidden>Nothing uploaded yet.</div>
</div>
<script>
(function(){
"use strict";
var drop = document.getElementById('drop'),
input = document.getElementById('file'),
pick = document.getElementById('pick'),
queue = document.getElementById('queue'),
list = document.getElementById('list'),
empty = document.getElementById('empty');
function human(n){
var u=['B','KB','MB','GB']; var i=0;
while(n>=1024 && i<3){ n/=1024; i++; }
return (i?n.toFixed(1):n)+' '+u[i];
}
/* ---- open the picker ---- */
function openPicker(e){ if(e) e.stopPropagation(); input.click(); }
pick.addEventListener('click', openPicker);
drop.addEventListener('click', openPicker);
drop.addEventListener('keydown', function(e){
if(e.key==='Enter'||e.key===' '){ e.preventDefault(); openPicker(); }
});
input.addEventListener('change', function(){
send(Array.prototype.slice.call(input.files));
input.value='';
});
/* ---- drag and drop ---- */
['dragenter','dragover'].forEach(function(ev){
drop.addEventListener(ev, function(e){
e.preventDefault(); e.stopPropagation(); drop.classList.add('hot');
});
});
['dragleave','drop'].forEach(function(ev){
drop.addEventListener(ev, function(e){
e.preventDefault(); e.stopPropagation(); drop.classList.remove('hot');
});
});
drop.addEventListener('drop', function(e){
if(e.dataTransfer && e.dataTransfer.files.length){
send(Array.prototype.slice.call(e.dataTransfer.files));
}
});
/* don't let a stray drop navigate away */
window.addEventListener('dragover', function(e){ e.preventDefault(); });
window.addEventListener('drop', function(e){ e.preventDefault(); });
/* ---- upload, one at a time, with real progress ---- */
function send(files){
if(!files.length) return;
files.forEach(function(f){ enqueue(f); });
}
var pending = [], busy = false;
function enqueue(file){
var row = document.createElement('div');
row.className = 'row';
row.innerHTML =
'<div class="top"><span class="nm"></span><span class="st">queued</span></div>'+
'<div class="bar"><i></i></div>';
row.querySelector('.nm').textContent = file.name;
queue.prepend(row);
pending.push({file:file, row:row});
pump();
}
function pump(){
if(busy || !pending.length) return;
busy = true;
var job = pending.shift(),
row = job.row, file = job.file,
st = row.querySelector('.st'),
bar = row.querySelector('.bar i');
var xhr = new XMLHttpRequest();
xhr.open('PUT', '/upload/put/' + encodeURIComponent(file.name));
xhr.setRequestHeader('Content-Type','application/octet-stream');
xhr.upload.addEventListener('progress', function(e){
if(!e.lengthComputable) return;
var pct = (e.loaded/e.total*100);
bar.style.width = pct.toFixed(1)+'%';
st.textContent = pct.toFixed(0)+'% ('+human(e.loaded)+' / '+human(e.total)+')';
});
xhr.addEventListener('load', function(){
var res = {};
try { res = JSON.parse(xhr.responseText); } catch(_){}
if(xhr.status===201 && res.ok){
row.classList.add('done');
bar.style.width='100%';
st.textContent = 'stored '+human(res.size);
row.querySelector('.nm').innerHTML =
'<a href="'+res.url+'" target="_blank" rel="noopener"></a>';
row.querySelector('a').textContent = res.name;
refresh();
} else {
row.classList.add('err');
bar.style.width='100%';
st.textContent = 'failed: '+(res.error || ('HTTP '+xhr.status));
}
busy=false; pump();
});
xhr.addEventListener('error', function(){
row.classList.add('err'); bar.style.width='100%';
st.textContent='network error'; busy=false; pump();
});
st.textContent='sending...';
xhr.send(file);
}
/* ---- stored file list ---- */
function refresh(){
fetch('/upload/list', {cache:'no-store'})
.then(function(r){ return r.json(); })
.then(function(d){
list.innerHTML='';
var files = d.files || [];
empty.hidden = files.length > 0;
files.forEach(function(f){
var tr = document.createElement('tr');
tr.innerHTML =
'<td><a target="_blank" rel="noopener"></a></td>'+
'<td class="sz"></td><td class="dt"></td>'+
'<td class="ac"><button class="del" title="Delete">&times;</button></td>';
var a = tr.querySelector('a');
a.href = f.url; a.textContent = f.name;
tr.querySelector('.sz').textContent = f.human;
tr.querySelector('.dt').textContent = f.mtime;
tr.querySelector('.del').addEventListener('click', function(){
if(!confirm('Delete '+f.name+'?')) return;
fetch('/upload/rm/'+encodeURIComponent(f.name), {method:'DELETE'})
.then(refresh);
});
list.appendChild(tr);
});
})
.catch(function(){ /* list is non-critical */ });
}
refresh();
})();
</script>
</body>
</html>