Sanitized public release v1.0.3
Source-Tag: v1.0.3 Manifest-SHA256: b7b09bf48f9e092ed8ad82f99c0319d6c3837d0a1549bd0cc4a374276c0f3896
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
"use strict";
|
||||
(function () {
|
||||
const CHUNK_SIZE = 4 * 1024 * 1024;
|
||||
const PREVIEW_MAX = 50 * 1024 * 1024;
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder("utf-8", {fatal:true});
|
||||
const state = {secret:null, objectId:null, manifest:null, metadata:null, chunks:null, url:null};
|
||||
const $ = id => document.getElementById(id);
|
||||
const show = id => ["idle","loading","viewer","error"].forEach(x => $(x).classList.toggle("hidden", x !== id));
|
||||
const progress = value => $("meter-fill").style.width = Math.round(value * 100) + "%";
|
||||
const from64 = value => { const s=value.replace(/-/g,"+").replace(/_/g,"/"); const raw=atob(s+"=".repeat((4-s.length%4)%4)); return Uint8Array.from(raw,c=>c.charCodeAt(0)); };
|
||||
const to64 = bytes => btoa(String.fromCharCode(...bytes)).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/g,"");
|
||||
const u32 = (v,b,o) => new DataView(b.buffer).setUint32(o,v,false);
|
||||
const u64 = (v,b,o) => new DataView(b.buffer).setBigUint64(o,BigInt(v),false);
|
||||
function aad(type, profile, id, size, count, index, length) {
|
||||
const out=new Uint8Array(60); out.set(enc.encode("NEODRP01")); out[8]=type; out[9]=profile; out.set(id,12);
|
||||
u64(size,out,36); u32(CHUNK_SIZE,out,44); u32(count,out,48); u32(index,out,52); u32(length,out,56); return out;
|
||||
}
|
||||
function iv(type,index) { const out=new Uint8Array(12); u32(type,out,0); u64(index,out,4); return out; }
|
||||
async function derive(secret, info, length) {
|
||||
const base=await crypto.subtle.importKey("raw",secret,"HKDF",false,["deriveBits"]);
|
||||
return new Uint8Array(await crypto.subtle.deriveBits({name:"HKDF",hash:"SHA-256",salt:enc.encode("{{NEODROP_CRYPTO_CONTEXT}}"),info:enc.encode(info)},base,length*8));
|
||||
}
|
||||
async function aes(raw) { return crypto.subtle.importKey("raw",raw,{name:"AES-GCM"},false,["decrypt"]); }
|
||||
async function decryptRecord(key, cipher, nonce, additional) { return new Uint8Array(await crypto.subtle.decrypt({name:"AES-GCM",iv:nonce,additionalData:additional,tagLength:128},key,cipher)); }
|
||||
async function fetchJson(url) { const response=await fetch(url,{cache:"no-store"}); if(!response.ok) throw new Error("unavailable"); return response.json(); }
|
||||
function typeFor(type,name) { const value=String(type||"").toLowerCase(); if(value&&value!=="application/octet-stream")return value; const match=String(name||"").toLowerCase().match(/\.([a-z0-9]+)$/); const types={png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",webp:"image/webp",avif:"image/avif",flac:"audio/flac",m4a:"audio/mp4",mp3:"audio/mpeg",oga:"audio/ogg",ogg:"audio/ogg",opus:"audio/opus",wav:"audio/wav",wave:"audio/wav",m4v:"video/mp4",mkv:"video/x-matroska",mov:"video/quicktime",mp4:"video/mp4",ogv:"video/ogg",webm:"video/webm"}; return match&&types[match[1]]?types[match[1]]:"application/octet-stream"; }
|
||||
function classify(type,name) { type=typeFor(type,name); if(/^image\/(png|jpeg|gif|webp|avif)$/i.test(type)) return "image"; if(/^audio\//i.test(type)) return "audio"; if(/^video\//i.test(type)) return "video"; if(/^text\//i.test(type)||/^(application\/(json|javascript|x-.*script))$/i.test(type)) return "text"; return "binary"; }
|
||||
function formatSize(size) { const units=["B","KiB","MiB","GiB"]; let i=0,n=size; while(n>=1024&&i<3){n/=1024;i++;} return n.toFixed(i?1:0)+" "+units[i]; }
|
||||
async function decryptChunk(index, key) {
|
||||
const response=await fetch(`/api/v1/objects/${state.objectId}/chunks/${index}`,{cache:"no-store"}); if(!response.ok) throw new Error("unavailable");
|
||||
const cipher=new Uint8Array(await response.arrayBuffer()); const plainLength=Math.min(CHUNK_SIZE,state.manifest.size-index*CHUNK_SIZE);
|
||||
if(cipher.length!==plainLength+16) throw new Error("invalid length");
|
||||
return decryptRecord(key,cipher,iv(1,index),aad(1,state.manifest.profile,from64(state.objectId),state.manifest.size,state.manifest.chunks,index,plainLength));
|
||||
}
|
||||
async function allChunks(key) { const parts=[]; for(let i=0;i<state.manifest.chunks;i++){parts.push(await decryptChunk(i,key));progress((i+1)/(state.manifest.chunks+1));} return parts; }
|
||||
function highlightText(text) {
|
||||
const pre=document.createElement("pre"); pre.className="text-preview"; let fenced=false;
|
||||
text.split("\n").forEach((line,i)=>{ const span=document.createElement("span"); if(/^```/.test(line)){fenced=!fenced;span.className="code";} else if(fenced||/^ {4}/.test(line))span.className="code"; else if(/^#{1,6}\s/.test(line))span.className="heading"; else if(/^>\s?/.test(line))span.className="quote"; else if(/^\s*([-*+] |\d+\. )/.test(line))span.className="list"; span.textContent=line+(i<text.split("\n").length-1?"\n":""); pre.appendChild(span); });
|
||||
return pre;
|
||||
}
|
||||
function setDetails() {
|
||||
$("file-name").textContent=state.metadata.name;
|
||||
$("file-size").textContent=`${formatSize(state.manifest.size)} (${state.manifest.size.toLocaleString("en-US")} bytes)`;
|
||||
$("file-type").textContent=state.metadata.type||"application/octet-stream";
|
||||
$("file-sha256").textContent=state.metadata.sha256||"Not provided by uploader";
|
||||
const expires=new Date(state.manifest.expiresAt*1000); $("file-expires").dateTime=expires.toISOString(); $("file-expires").textContent=expires.toISOString();
|
||||
}
|
||||
async function renderPreview(parts) {
|
||||
const blob=new Blob(parts,{type:typeFor(state.metadata.type,state.metadata.name)}); const kind=classify(state.metadata.type,state.metadata.name); const preview=$("preview"); preview.textContent="";
|
||||
if(kind==="image"&&blob.size<=PREVIEW_MAX){const image=document.createElement("img");image.alt=state.metadata.name;image.src=URL.createObjectURL(blob);preview.appendChild(image);}
|
||||
else if((kind==="audio"||kind==="video")&&blob.size<=PREVIEW_MAX){const media=document.createElement(kind);media.controls=true;media.preload="metadata";media.src=URL.createObjectURL(blob);if(kind==="video")media.playsInline=true;preview.appendChild(media);}
|
||||
else if(kind==="text"&&blob.size<=10*1024*1024){preview.appendChild(highlightText(dec.decode(await blob.arrayBuffer())));}
|
||||
else {const p=document.createElement("p");p.textContent="Encrypted binary object. Use Download to decrypt and save it.";preview.appendChild(p);}
|
||||
setDetails(); show("viewer");
|
||||
$("download").onclick=()=>{const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=state.metadata.name||"download.bin";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),30000);};
|
||||
}
|
||||
async function downloadLarge(key) {
|
||||
try {
|
||||
if(window.showSaveFilePicker){
|
||||
const handle=await window.showSaveFilePicker({suggestedName:state.metadata.name||"download.bin"}); const writable=await handle.createWritable();
|
||||
try { for(let i=0;i<state.manifest.chunks;i++){await writable.write(await decryptChunk(i,key));progress((i+1)/state.manifest.chunks);} await writable.close(); }
|
||||
catch(error){await writable.abort();throw error;}
|
||||
} else {
|
||||
const parts=await allChunks(key); const blob=new Blob(parts,{type:state.metadata.type||"application/octet-stream"}); const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=state.metadata.name||"download.bin";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),30000);
|
||||
}
|
||||
} catch(error) { if(error.name!=="AbortError")show("error"); }
|
||||
}
|
||||
function renderDownloadOnly(key) {
|
||||
const preview=$("preview"); preview.textContent=""; const p=document.createElement("p"); p.textContent="Encrypted binary object. Decryption starts only when you choose Download."; preview.appendChild(p);
|
||||
setDetails(); show("viewer"); $("download").onclick=()=>downloadLarge(key);
|
||||
}
|
||||
async function load() {
|
||||
try {
|
||||
if(new URLSearchParams(location.search).get("embed")==="1")document.body.classList.add("embed");
|
||||
const match=location.pathname.match(/^\/d\/([A-Za-z0-9_-]{32})$/); const fragment=location.hash.match(/^#d1\.([A-Za-z0-9_-]{43})$/); if(!match||!fragment)return;
|
||||
show("loading"); state.url=location.href; state.objectId=match[1]; state.secret=from64(fragment[1]); history.replaceState(null,"",location.pathname+location.search);
|
||||
const derivedId=await derive(state.secret,"object-id",24); if(to64(derivedId)!==state.objectId)throw new Error("identifier mismatch");
|
||||
state.manifest=await fetchJson(`/api/v1/objects/${state.objectId}`); if(state.manifest.v!==1||state.manifest.chunkSize!==CHUNK_SIZE)throw new Error("manifest");
|
||||
const metaKey=await aes(await derive(state.secret,"metadata-key",32)); const metaCipher=from64(state.manifest.metadata); const metaLength=metaCipher.length-16;
|
||||
const plain=await decryptRecord(metaKey,metaCipher,iv(2,0),aad(0,state.manifest.profile,derivedId,state.manifest.size,state.manifest.chunks,0xffffffff,metaLength)); state.metadata=JSON.parse(dec.decode(plain));
|
||||
if(state.metadata.v!==1||state.metadata.size!==state.manifest.size||(state.metadata.sha256!==undefined&&!/^[a-f0-9]{64}$/.test(state.metadata.sha256)))throw new Error("metadata"); progress(1/(state.manifest.chunks+1));
|
||||
const contentKey=await aes(await derive(state.secret,"content-key",32)); const kind=classify(state.metadata.type,state.metadata.name);
|
||||
if(((kind==="image"||kind==="audio"||kind==="video")&&state.manifest.size<=PREVIEW_MAX)||(kind==="text"&&state.manifest.size<=10*1024*1024)){state.chunks=await allChunks(contentKey);await renderPreview(state.chunks);progress(1);} else {renderDownloadOnly(contentKey);}
|
||||
} catch(e) { show("error"); }
|
||||
}
|
||||
load();
|
||||
})();
|
||||
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="theme-color" content="#150a24">
|
||||
<title>{{PRODUCT_NAME}} - Secure File Drop</title>
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<link rel="stylesheet" href="/style.css?v=7">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="login-hero" aria-labelledby="neodrop-title">
|
||||
<div class="login-portrait">
|
||||
<img src="/avatar-login.jpg" alt="Operator-provided avatar">
|
||||
<span class="portrait-code" aria-hidden="true">{{DROP_NODE_LABEL}}</span>
|
||||
</div>
|
||||
<div class="login-intro">
|
||||
<p class="login-kicker">PRIVATE RELAY // TLS 1.3</p>
|
||||
<h1 id="neodrop-title">{{DROPS_TITLE_HTML}}</h1>
|
||||
<p class="login-deck">{{DROP_TAGLINE}}</p>
|
||||
<div class="login-status" aria-label="Service capabilities">
|
||||
<span>ZERO KNOWLEDGE</span>
|
||||
<span>CLIENT ENCRYPTED</span>
|
||||
<span>CIPHERTEXT ONLY</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="idle" class="terminal" aria-label="NeoDrop service status">
|
||||
<div class="terminal-bar"><span></span><span></span><span></span><b>drops@relay:~</b></div>
|
||||
<div class="terminal-body">
|
||||
<p><span class="prompt">>></span> neodrop status --public</p>
|
||||
<p class="terminal-title">Secure File Drop</p>
|
||||
<p>Encrypted in your browser. The server stores ciphertext and never receives the key in this link.</p>
|
||||
<dl>
|
||||
<div><dt>ENCRYPTION</dt><dd>AES-256-GCM</dd></div>
|
||||
<div><dt>ACCESS</dt><dd>CAPABILITY LINK</dd></div>
|
||||
<div><dt>STORAGE</dt><dd>CIPHERTEXT ONLY</dd></div>
|
||||
</dl>
|
||||
<p class="input-line"><span class="prompt">>></span> <span class="cursor" aria-hidden="true">_</span><span class="sr-only">Awaiting input</span></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="loading" class="panel hidden" aria-live="polite">
|
||||
<p class="status">AUTHENTICATING ENCRYPTED OBJECT</p>
|
||||
<div class="meter"><span id="meter-fill"></span></div>
|
||||
</section>
|
||||
|
||||
<section id="viewer" class="panel hidden">
|
||||
<div class="file-heading">
|
||||
<div><p class="label">DECRYPTED OBJECT</p><h1 id="file-name"></h1></div>
|
||||
<button id="download">Download</button>
|
||||
</div>
|
||||
<dl class="file-details">
|
||||
<div><dt>SIZE</dt><dd id="file-size"></dd></div>
|
||||
<div><dt>SHA-256</dt><dd><code id="file-sha256"></code></dd></div>
|
||||
<div><dt>TYPE</dt><dd id="file-type"></dd></div>
|
||||
<div><dt>EXPIRES</dt><dd><time id="file-expires"></time></dd></div>
|
||||
</dl>
|
||||
<div id="preview" class="preview"></div>
|
||||
</section>
|
||||
|
||||
<section id="error" class="panel error hidden" role="alert">
|
||||
<p class="status">OBJECT UNAVAILABLE</p>
|
||||
<p>This link is invalid, expired, deleted, or could not be authenticated.</p>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/app.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,613 @@
|
||||
#!/usr/bin/python3
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
APP_ROOT = Path("/usr/share/neodrop")
|
||||
DATA_ROOT = Path("{{NEODROP_DATA_ROOT}}")
|
||||
STAGING_ROOT = DATA_ROOT / "staging"
|
||||
OBJECT_ROOT = DATA_ROOT / "objects"
|
||||
DB_PATH = DATA_ROOT / "neodrop.sqlite3"
|
||||
MANAGER_TOKEN_HASH_PATH = Path("{{NEODROP_MANAGER_TOKEN_HASH_PATH}}")
|
||||
LISTEN_ADDRESS = "{{NEODROP_BIND_ADDRESS}}"
|
||||
LISTEN_PORT = 8080
|
||||
TRUSTED_PROXY = ipaddress.ip_address("{{TRUSTED_PROXY_ADDRESS}}")
|
||||
TRUSTED_UPLOADERS = (
|
||||
ipaddress.ip_network("{{UPLOAD_CIDR_1}}"),
|
||||
ipaddress.ip_network("{{UPLOAD_CIDR_2}}"),
|
||||
ipaddress.ip_network("{{UPLOAD_CIDR_3}}"),
|
||||
)
|
||||
ALLOWED_ORIGIN = "{{RELAY_ORIGIN}}"
|
||||
MANAGEMENT_ORIGIN = "{{DROPS_ORIGIN}}"
|
||||
MANAGEMENT_UPLOADERS = (
|
||||
ipaddress.ip_network("{{MANAGEMENT_CIDR_1}}"),
|
||||
ipaddress.ip_network("{{MANAGEMENT_CIDR_2}}"),
|
||||
ipaddress.ip_network("{{MANAGEMENT_CIDR_3}}"),
|
||||
)
|
||||
UPLOAD_ORIGINS = (ALLOWED_ORIGIN, MANAGEMENT_ORIGIN)
|
||||
CHUNK_SIZE = 4 * 1024 * 1024
|
||||
PROFILES = {
|
||||
0: (50 * 1024 * 1024, 30 * 24 * 60 * 60),
|
||||
1: (1024 * 1024 * 1024, 7 * 24 * 60 * 60),
|
||||
}
|
||||
UPLOAD_TTL = 24 * 60 * 60
|
||||
ID_RE = re.compile(r"^[A-Za-z0-9_-]{32}$")
|
||||
UPLOAD_RE = re.compile(r"^[A-Za-z0-9_-]{22}$")
|
||||
UNAVAILABLE = b'{"error":"unavailable"}\n'
|
||||
DB_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def now():
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def b64url(data):
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def token_hash(token):
|
||||
return hashlib.sha256(token.encode("ascii")).digest()
|
||||
|
||||
|
||||
def valid_manager_token(token):
|
||||
if not token or len(token) > 128:
|
||||
return False
|
||||
try:
|
||||
expected = MANAGER_TOKEN_HASH_PATH.read_text().strip()
|
||||
actual = hashlib.sha256(token.encode("ascii", "strict")).hexdigest()
|
||||
except (OSError, UnicodeEncodeError):
|
||||
return False
|
||||
return len(expected) == 64 and hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
def valid_object_id(value):
|
||||
if not ID_RE.fullmatch(value or ""):
|
||||
return False
|
||||
try:
|
||||
return len(base64.urlsafe_b64decode(value + "==")) == 24
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def database():
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def initialize_database():
|
||||
DATA_ROOT.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
STAGING_ROOT.mkdir(mode=0o700, exist_ok=True)
|
||||
OBJECT_ROOT.mkdir(mode=0o700, exist_ok=True)
|
||||
with database() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tombstones (
|
||||
object_id TEXT PRIMARY KEY,
|
||||
first_seen INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS uploads (
|
||||
upload_id TEXT PRIMARY KEY,
|
||||
object_id TEXT UNIQUE NOT NULL,
|
||||
token_hash BLOB NOT NULL,
|
||||
profile INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
chunks INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS objects (
|
||||
object_id TEXT PRIMARY KEY,
|
||||
token_hash BLOB NOT NULL,
|
||||
profile INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
chunks INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS objects_expiry ON objects(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS uploads_created ON uploads(created_at);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def object_path(object_id):
|
||||
return OBJECT_ROOT / object_id[:2] / object_id
|
||||
|
||||
|
||||
def report_invalid(client_ip, source, detail, destination_port=None):
|
||||
event = {
|
||||
"ip": str(client_ip),
|
||||
"source": source,
|
||||
"detail": detail[:160],
|
||||
}
|
||||
if destination_port is not None:
|
||||
event["destination_port"] = destination_port
|
||||
payload = json.dumps(event).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
"{{NEOGUARD_INTERNAL_URL}}",
|
||||
data=payload,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(request, timeout=0.25).close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def cleanup():
|
||||
cutoff = now() - UPLOAD_TTL
|
||||
expired_paths = []
|
||||
object_paths = []
|
||||
with DB_LOCK, database() as conn:
|
||||
for row in conn.execute("SELECT upload_id FROM uploads WHERE created_at <= ?", (cutoff,)):
|
||||
expired_paths.append(STAGING_ROOT / row["upload_id"])
|
||||
conn.execute("DELETE FROM uploads WHERE created_at <= ?", (cutoff,))
|
||||
for row in conn.execute("SELECT object_id FROM objects WHERE expires_at <= ?", (now(),)):
|
||||
object_paths.append((row["object_id"], object_path(row["object_id"])))
|
||||
for path in expired_paths:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
for object_id, path in object_paths:
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
continue
|
||||
with DB_LOCK, database() as conn:
|
||||
conn.execute("DELETE FROM objects WHERE object_id = ? AND expires_at <= ?", (object_id, now()))
|
||||
|
||||
|
||||
def cleanup_loop():
|
||||
while True:
|
||||
try:
|
||||
cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(3600)
|
||||
|
||||
|
||||
class NeoDropHandler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "NeoDrop"
|
||||
sys_version = ""
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print("%s %s" % (self.address_string(), fmt % args), flush=True)
|
||||
|
||||
def client_ip(self):
|
||||
peer = ipaddress.ip_address(self.client_address[0])
|
||||
if peer == TRUSTED_PROXY:
|
||||
forwarded = self.headers.get("X-Real-IP", "")
|
||||
try:
|
||||
return ipaddress.ip_address(forwarded)
|
||||
except ValueError:
|
||||
return peer
|
||||
return peer
|
||||
|
||||
def destination_port(self):
|
||||
peer = ipaddress.ip_address(self.client_address[0])
|
||||
if peer == TRUSTED_PROXY:
|
||||
value = self.headers.get("X-Forwarded-Port", "")
|
||||
if value.isdecimal() and 1 <= int(value) <= 65535:
|
||||
return int(value)
|
||||
return None
|
||||
return int(self.server.server_address[1])
|
||||
|
||||
def is_trusted_uploader(self):
|
||||
address = self.client_ip()
|
||||
return any(address in network for network in TRUSTED_UPLOADERS)
|
||||
|
||||
def send_headers(self, status, content_type, length, extra=None):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(length))
|
||||
self.send_header("Cache-Control", "private, no-store, max-age=0")
|
||||
self.send_header("Pragma", "no-cache")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
if extra:
|
||||
for key, value in extra.items():
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
|
||||
def send_json(self, status, value, cors=False):
|
||||
body = json.dumps(value, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
extra = {}
|
||||
if cors:
|
||||
origin = self.headers.get("Origin", "")
|
||||
if origin in UPLOAD_ORIGINS:
|
||||
extra.update({
|
||||
"Access-Control-Allow-Origin": origin,
|
||||
"Vary": "Origin",
|
||||
})
|
||||
self.send_headers(status, "application/json", len(body), extra)
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def unavailable(self, detail="unavailable"):
|
||||
report_invalid(self.client_ip(), "drops", detail, self.destination_port())
|
||||
self.send_headers(404, "application/json", len(UNAVAILABLE))
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(UNAVAILABLE)
|
||||
|
||||
def forbidden_upload(self, detail):
|
||||
self.close_connection = True
|
||||
report_invalid(self.client_ip(), "drops", detail, self.destination_port())
|
||||
self.send_json(403, {"error": "forbidden"}, cors=True)
|
||||
|
||||
def read_json(self, maximum=8192):
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "-1"))
|
||||
except ValueError:
|
||||
return None
|
||||
if length < 0 or length > maximum:
|
||||
return None
|
||||
try:
|
||||
return json.loads(self.rfile.read(length))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
def upload_authorized(self):
|
||||
origin = self.headers.get("Origin")
|
||||
address = self.client_ip()
|
||||
if origin == ALLOWED_ORIGIN:
|
||||
source_allowed = any(address in network for network in TRUSTED_UPLOADERS)
|
||||
elif origin == MANAGEMENT_ORIGIN:
|
||||
source_allowed = any(address in network for network in MANAGEMENT_UPLOADERS)
|
||||
else:
|
||||
source_allowed = False
|
||||
if not source_allowed:
|
||||
self.forbidden_upload("external upload")
|
||||
return False
|
||||
if origin not in UPLOAD_ORIGINS:
|
||||
self.forbidden_upload("invalid upload origin")
|
||||
return False
|
||||
return True
|
||||
|
||||
def bearer(self):
|
||||
value = self.headers.get("Authorization", "")
|
||||
if not value.startswith("Bearer "):
|
||||
return ""
|
||||
return value[7:]
|
||||
|
||||
def manager_authorized(self):
|
||||
if not self.is_trusted_uploader():
|
||||
return False
|
||||
return valid_manager_token(self.bearer())
|
||||
|
||||
def lookup_upload(self, upload_id):
|
||||
token = self.bearer()
|
||||
if not token or len(token) > 128:
|
||||
return None
|
||||
with database() as conn:
|
||||
row = conn.execute("SELECT * FROM uploads WHERE upload_id = ?", (upload_id,)).fetchone()
|
||||
if not row or not hmac.compare_digest(row["token_hash"], token_hash(token)):
|
||||
return None
|
||||
if row["created_at"] + UPLOAD_TTL <= now():
|
||||
return None
|
||||
return row
|
||||
|
||||
def do_OPTIONS(self):
|
||||
if not self.upload_authorized():
|
||||
return
|
||||
path = urlsplit(self.path).path
|
||||
requested = self.headers.get("Access-Control-Request-Method", "")
|
||||
valid = (
|
||||
(requested == "POST" and (
|
||||
path == "/api/v1/uploads"
|
||||
or re.fullmatch(r"/api/v1/uploads/[A-Za-z0-9_-]{22}/commit", path)
|
||||
))
|
||||
or (requested == "PUT" and (
|
||||
re.fullmatch(r"/api/v1/uploads/[A-Za-z0-9_-]{22}/metadata", path)
|
||||
or re.fullmatch(r"/api/v1/uploads/[A-Za-z0-9_-]{22}/chunks/\d{1,4}", path)
|
||||
))
|
||||
or (requested == "DELETE" and re.fullmatch(
|
||||
r"/api/v1/objects/[A-Za-z0-9_-]{32}", path
|
||||
))
|
||||
)
|
||||
requested_headers = {
|
||||
value.strip().lower()
|
||||
for value in self.headers.get("Access-Control-Request-Headers", "").split(",")
|
||||
if value.strip()
|
||||
}
|
||||
if not valid or not requested_headers.issubset({"authorization", "content-type"}):
|
||||
return self.unavailable("invalid preflight")
|
||||
self.send_response(204)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.send_header("Access-Control-Allow-Origin", self.headers["Origin"])
|
||||
self.send_header("Access-Control-Allow-Methods", requested + ", OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
self.send_header("Access-Control-Max-Age", "600")
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/api/v1/uploads":
|
||||
return self.create_upload()
|
||||
match = re.fullmatch(r"/api/v1/uploads/([A-Za-z0-9_-]{22})/commit", path)
|
||||
if match:
|
||||
return self.commit_upload(match.group(1))
|
||||
self.unavailable("invalid POST path")
|
||||
|
||||
def create_upload(self):
|
||||
if not self.upload_authorized():
|
||||
return
|
||||
if self.headers.get("Content-Type") != "application/json":
|
||||
self.close_connection = True
|
||||
return self.send_json(415, {"error": "invalid request"}, cors=True)
|
||||
data = self.read_json()
|
||||
if not isinstance(data, dict):
|
||||
return self.send_json(400, {"error": "invalid request"}, cors=True)
|
||||
try:
|
||||
object_id = data["id"]
|
||||
profile = int(data["profile"])
|
||||
size = int(data["size"])
|
||||
chunk_size = int(data["chunkSize"])
|
||||
chunks = int(data["chunks"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return self.send_json(400, {"error": "invalid request"}, cors=True)
|
||||
if not valid_object_id(object_id) or profile not in PROFILES:
|
||||
return self.send_json(400, {"error": "invalid request"}, cors=True)
|
||||
maximum, _ = PROFILES[profile]
|
||||
expected_chunks = (size + CHUNK_SIZE - 1) // CHUNK_SIZE if size else 0
|
||||
if size < 0 or size > maximum or chunk_size != CHUNK_SIZE or chunks != expected_chunks:
|
||||
return self.send_json(400, {"error": "invalid request"}, cors=True)
|
||||
upload_id = b64url(secrets.token_bytes(16))
|
||||
token = b64url(secrets.token_bytes(32))
|
||||
created = now()
|
||||
with DB_LOCK:
|
||||
try:
|
||||
with database() as conn:
|
||||
conn.execute("INSERT INTO tombstones VALUES (?, ?)", (object_id, created))
|
||||
conn.execute(
|
||||
"INSERT INTO uploads VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(upload_id, object_id, token_hash(token), profile, size, chunks, created),
|
||||
)
|
||||
stage = STAGING_ROOT / upload_id
|
||||
(stage / "chunks").mkdir(mode=0o700, parents=True)
|
||||
except (sqlite3.IntegrityError, FileExistsError):
|
||||
return self.send_json(409, {"error": "identifier unavailable"}, cors=True)
|
||||
self.send_json(201, {
|
||||
"v": 1,
|
||||
"uploadId": upload_id,
|
||||
"uploadToken": token,
|
||||
"sessionExpiresAt": created + UPLOAD_TTL,
|
||||
}, cors=True)
|
||||
|
||||
def do_PUT(self):
|
||||
path = urlsplit(self.path).path
|
||||
metadata = re.fullmatch(r"/api/v1/uploads/([A-Za-z0-9_-]{22})/metadata", path)
|
||||
chunk = re.fullmatch(r"/api/v1/uploads/([A-Za-z0-9_-]{22})/chunks/(\d{1,4})", path)
|
||||
if metadata:
|
||||
return self.put_record(metadata.group(1), None)
|
||||
if chunk:
|
||||
return self.put_record(chunk.group(1), int(chunk.group(2)))
|
||||
self.unavailable("invalid PUT path")
|
||||
|
||||
def put_record(self, upload_id, index):
|
||||
if not self.upload_authorized():
|
||||
return
|
||||
if self.headers.get("Content-Type") != "application/octet-stream":
|
||||
self.close_connection = True
|
||||
return self.send_json(415, {"error": "invalid record"}, cors=True)
|
||||
row = self.lookup_upload(upload_id)
|
||||
if not row:
|
||||
return self.forbidden_upload("invalid upload authorization")
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "-1"))
|
||||
except ValueError:
|
||||
length = -1
|
||||
if index is None:
|
||||
target = STAGING_ROOT / upload_id / "metadata.bin"
|
||||
valid_length = 16 <= length <= 4112
|
||||
else:
|
||||
if index < 0 or index >= row["chunks"]:
|
||||
valid_length = False
|
||||
expected = -1
|
||||
else:
|
||||
remaining = row["size"] - index * CHUNK_SIZE
|
||||
expected = min(CHUNK_SIZE, remaining) + 16
|
||||
valid_length = length == expected
|
||||
target = STAGING_ROOT / upload_id / "chunks" / ("%08d.bin" % index)
|
||||
if not valid_length:
|
||||
return self.send_json(400, {"error": "invalid record"}, cors=True)
|
||||
body = self.rfile.read(length)
|
||||
if len(body) != length:
|
||||
return self.send_json(400, {"error": "incomplete record"}, cors=True)
|
||||
if target.exists():
|
||||
if target.read_bytes() != body:
|
||||
return self.send_json(409, {"error": "immutable record"}, cors=True)
|
||||
else:
|
||||
temporary = target.with_name(target.name + "." + secrets.token_hex(8) + ".tmp")
|
||||
with open(temporary, "xb") as handle:
|
||||
handle.write(body)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, target)
|
||||
self.send_json(200, {"ok": True}, cors=True)
|
||||
|
||||
def commit_upload(self, upload_id):
|
||||
if not self.upload_authorized():
|
||||
return
|
||||
if self.headers.get("Content-Type") != "application/json":
|
||||
self.close_connection = True
|
||||
return self.send_json(415, {"error": "invalid request"}, cors=True)
|
||||
data = self.read_json(maximum=64)
|
||||
if data != {}:
|
||||
return self.send_json(400, {"error": "invalid request"}, cors=True)
|
||||
row = self.lookup_upload(upload_id)
|
||||
if not row:
|
||||
return self.forbidden_upload("invalid commit authorization")
|
||||
stage = STAGING_ROOT / upload_id
|
||||
metadata = stage / "metadata.bin"
|
||||
if not metadata.is_file() or not 16 <= metadata.stat().st_size <= 4112:
|
||||
return self.send_json(409, {"error": "upload incomplete"}, cors=True)
|
||||
for index in range(row["chunks"]):
|
||||
path = stage / "chunks" / ("%08d.bin" % index)
|
||||
remaining = row["size"] - index * CHUNK_SIZE
|
||||
if not path.is_file() or path.stat().st_size != min(CHUNK_SIZE, remaining) + 16:
|
||||
return self.send_json(409, {"error": "upload incomplete"}, cors=True)
|
||||
created = now()
|
||||
expires = created + PROFILES[row["profile"]][1]
|
||||
destination = object_path(row["object_id"])
|
||||
destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
moved = False
|
||||
with DB_LOCK:
|
||||
try:
|
||||
os.rename(stage, destination)
|
||||
moved = True
|
||||
with database() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO objects VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(row["object_id"], row["token_hash"], row["profile"], row["size"],
|
||||
row["chunks"], created, expires),
|
||||
)
|
||||
conn.execute("DELETE FROM uploads WHERE upload_id = ?", (upload_id,))
|
||||
except Exception:
|
||||
if moved and destination.exists() and not stage.exists():
|
||||
os.rename(destination, stage)
|
||||
raise
|
||||
self.send_json(200, {"v": 1, "expiresAt": expires}, cors=True)
|
||||
|
||||
def do_DELETE(self):
|
||||
path = urlsplit(self.path).path
|
||||
match = re.fullmatch(r"/api/v1/objects/([A-Za-z0-9_-]{32})", path)
|
||||
if not match or not self.upload_authorized():
|
||||
return
|
||||
if self.headers.get("Content-Length", "0") != "0":
|
||||
self.close_connection = True
|
||||
return self.send_json(400, {"error": "invalid request"}, cors=True)
|
||||
object_id = match.group(1)
|
||||
token = self.bearer()
|
||||
target = object_path(object_id)
|
||||
with DB_LOCK, database() as conn:
|
||||
row = conn.execute("SELECT token_hash FROM objects WHERE object_id = ?", (object_id,)).fetchone()
|
||||
if not row or not token or not hmac.compare_digest(row["token_hash"], token_hash(token)):
|
||||
return self.forbidden_upload("invalid delete authorization")
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
conn.execute("DELETE FROM objects WHERE object_id = ?", (object_id,))
|
||||
self.send_json(200, {"deleted": True}, cors=True)
|
||||
|
||||
def do_HEAD(self):
|
||||
return self.do_GET()
|
||||
|
||||
def do_GET(self):
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/api/v1/manager/objects":
|
||||
if not self.manager_authorized():
|
||||
return self.response_forbidden()
|
||||
return self.manager_inventory()
|
||||
manifest = re.fullmatch(r"/api/v1/objects/([A-Za-z0-9_-]{32})", path)
|
||||
chunk = re.fullmatch(r"/api/v1/objects/([A-Za-z0-9_-]{32})/chunks/(\d{1,4})", path)
|
||||
if manifest:
|
||||
return self.get_manifest(manifest.group(1))
|
||||
if chunk:
|
||||
return self.get_chunk(chunk.group(1), int(chunk.group(2)))
|
||||
if path == "/" or re.fullmatch(r"/d/[A-Za-z0-9_-]{32}", path):
|
||||
return self.static_file("index.html")
|
||||
if path in ("/app.js", "/style.css", "/favicon.png", "/avatar-login.jpg"):
|
||||
return self.static_file(path[1:])
|
||||
self.unavailable("invalid GET path")
|
||||
|
||||
def response_forbidden(self):
|
||||
self.send_json(403, {"error": "forbidden"})
|
||||
|
||||
def manager_inventory(self):
|
||||
with database() as conn:
|
||||
rows = list(conn.execute(
|
||||
"SELECT object_id,profile,size,chunks,created_at,expires_at "
|
||||
"FROM objects ORDER BY created_at DESC LIMIT 10000"
|
||||
))
|
||||
self.send_json(200, {"objects": [
|
||||
{
|
||||
"id": row["object_id"],
|
||||
"profile": row["profile"],
|
||||
"size": row["size"],
|
||||
"chunks": row["chunks"],
|
||||
"createdAt": row["created_at"],
|
||||
"expiresAt": row["expires_at"],
|
||||
}
|
||||
for row in rows
|
||||
]})
|
||||
|
||||
def active_object(self, object_id):
|
||||
if not valid_object_id(object_id):
|
||||
return None
|
||||
with database() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM objects WHERE object_id = ? AND expires_at > ?",
|
||||
(object_id, now()),
|
||||
).fetchone()
|
||||
|
||||
def get_manifest(self, object_id):
|
||||
row = self.active_object(object_id)
|
||||
metadata = object_path(object_id) / "metadata.bin"
|
||||
if not row or not metadata.is_file():
|
||||
return self.unavailable("unknown object")
|
||||
self.send_json(200, {
|
||||
"v": 1,
|
||||
"profile": row["profile"],
|
||||
"size": row["size"],
|
||||
"chunkSize": CHUNK_SIZE,
|
||||
"chunks": row["chunks"],
|
||||
"metadata": b64url(metadata.read_bytes()),
|
||||
"expiresAt": row["expires_at"],
|
||||
})
|
||||
|
||||
def get_chunk(self, object_id, index):
|
||||
row = self.active_object(object_id)
|
||||
if not row or index < 0 or index >= row["chunks"]:
|
||||
return self.unavailable("unknown object chunk")
|
||||
path = object_path(object_id) / "chunks" / ("%08d.bin" % index)
|
||||
if not path.is_file():
|
||||
return self.unavailable("unknown object chunk")
|
||||
length = path.stat().st_size
|
||||
self.send_headers(200, "application/octet-stream", length)
|
||||
if self.command != "HEAD":
|
||||
with open(path, "rb") as handle:
|
||||
shutil.copyfileobj(handle, self.wfile, length=64 * 1024)
|
||||
|
||||
def static_file(self, name):
|
||||
path = APP_ROOT / name
|
||||
if not path.is_file():
|
||||
return self.unavailable("missing application asset")
|
||||
types = {"index.html": "text/html; charset=utf-8", "app.js": "application/javascript",
|
||||
"style.css": "text/css", "favicon.png": "image/png",
|
||||
"avatar-login.jpg": "image/jpeg"}
|
||||
body = path.read_bytes()
|
||||
cached = name in ("favicon.png", "avatar-login.jpg")
|
||||
extra = {"Cache-Control": "public, max-age=604800"} if cached else {"Cache-Control": "no-cache"}
|
||||
self.send_headers(200, types[name], len(body), extra)
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
class NeoDropServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
request_queue_size = 256
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.umask(0o077)
|
||||
initialize_database()
|
||||
cleanup()
|
||||
threading.Thread(target=cleanup_loop, daemon=True).start()
|
||||
server = NeoDropServer((LISTEN_ADDRESS, LISTEN_PORT), NeoDropHandler)
|
||||
server.serve_forever()
|
||||
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--void:#07030d; --panel:#150a24; --raised:#211034; --violet:#9d5cff;
|
||||
--purple:#6f2cff; --pink:#ff65c8; --cyan:#55e7ff; --acid:#b9ff4f;
|
||||
--text:#f1eaff; --muted:#aa9abd; --line:rgba(157,92,255,.38);
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
html,body { min-height:100%; margin:0; }
|
||||
body {
|
||||
color:var(--text); background-color:var(--void);
|
||||
background-image:linear-gradient(rgba(157,92,255,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(157,92,255,.045) 1px,transparent 1px),radial-gradient(circle at 80% 0,rgba(111,44,255,.24),transparent 38%);
|
||||
background-size:32px 32px,32px 32px,auto;
|
||||
font-family:"OpenDyslexic","Atkinson Hyperlegible",system-ui,sans-serif;
|
||||
}
|
||||
main { width:min(1120px,calc(100% - 40px)); margin:0 auto; padding:54px 0 80px; }
|
||||
.label,.status { color:var(--acid); font:700 10px/1.4 ui-monospace,monospace; letter-spacing:.18em; }
|
||||
.login-hero { display:grid; grid-template-columns:minmax(250px,.78fr) minmax(360px,1.35fr); min-height:390px; margin-bottom:28px; overflow:hidden; border-top:1px solid var(--violet); border-bottom:1px solid var(--line); background:linear-gradient(115deg,rgba(22,9,40,.96),rgba(9,3,17,.78)),linear-gradient(90deg,transparent 49.8%,rgba(85,231,255,.12) 50%,transparent 50.2%); box-shadow:20px 20px 0 rgba(72,24,122,.12); }
|
||||
.login-portrait { position:relative; min-height:390px; overflow:hidden; border-right:1px solid var(--line); }
|
||||
.login-portrait::after { position:absolute; inset:0; content:""; pointer-events:none; background:repeating-linear-gradient(0deg,transparent 0 4px,rgba(7,3,13,.16) 5px),linear-gradient(180deg,transparent 55%,rgba(7,3,13,.8)); }
|
||||
.login-portrait img { width:100%; height:100%; min-height:390px; object-fit:cover; object-position:50% 27%; filter:saturate(1.12) contrast(1.04); }
|
||||
.portrait-code { position:absolute; z-index:1; right:14px; bottom:12px; color:var(--acid); font:700 10px/1 ui-monospace,monospace; letter-spacing:.18em; }
|
||||
.login-intro { position:relative; align-self:center; padding:48px clamp(32px,7vw,86px); }
|
||||
.login-intro::before { position:absolute; top:28px; left:0; width:46px; height:3px; content:""; background:var(--pink); box-shadow:19px 8px 0 var(--cyan); }
|
||||
.login-kicker { margin:0 0 20px; color:var(--acid); font:700 11px/1.4 ui-monospace,monospace; letter-spacing:.2em; }
|
||||
.login-intro h1 { margin:0; color:var(--text); font-family:"Arial Black",Impact,sans-serif; font-size:clamp(42px,6.4vw,76px); font-weight:900; line-height:.78; letter-spacing:-.07em; text-transform:uppercase; text-shadow:4px 4px 0 rgba(111,44,255,.65); }
|
||||
.login-intro h1 span { display:block; margin-left:.32em; color:transparent; -webkit-text-stroke:1px var(--pink); text-shadow:none; text-transform:lowercase; }
|
||||
.login-deck { max-width:520px; margin:30px 0 25px; color:#cfc3dd; font-size:15px; line-height:1.75; }
|
||||
.login-status { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.login-status span { padding:7px 9px 6px; border:1px solid rgba(85,231,255,.35); color:var(--cyan); background:rgba(85,231,255,.04); font:700 9px/1 ui-monospace,monospace; letter-spacing:.12em; }
|
||||
.terminal { margin:28px clamp(20px,7vw,82px) 52px; border:1px solid rgba(157,92,255,.5); background:rgba(5,2,10,.94); box-shadow:12px 12px 0 rgba(111,44,255,.12); font:700 clamp(12px,1.8vw,16px)/1.75 ui-monospace,monospace; }
|
||||
.terminal-bar { display:flex; align-items:center; gap:8px; padding:10px 14px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; letter-spacing:.08em; }
|
||||
.terminal-bar span { width:8px; height:8px; border-radius:50%; background:var(--pink); }
|
||||
.terminal-bar span:nth-child(2) { background:var(--violet); }
|
||||
.terminal-bar span:nth-child(3) { background:var(--acid); }
|
||||
.terminal-bar b { margin-left:6px; font-weight:700; }
|
||||
.terminal-body { padding:clamp(20px,4vw,34px); }
|
||||
.terminal-body p { margin:0 0 8px; color:#c8c2cf; }
|
||||
.terminal-title { margin:20px 0 8px!important; color:var(--text)!important; font-size:clamp(20px,3vw,32px); text-transform:uppercase; }
|
||||
.terminal .prompt { color:var(--violet); }
|
||||
.terminal dl { margin:24px 0 0; }
|
||||
.terminal dt { color:var(--muted); }
|
||||
.terminal dd { color:var(--cyan); }
|
||||
.input-line { margin-top:18px!important; }
|
||||
.cursor { color:var(--acid); animation:blink 1s steps(1,end) infinite; }
|
||||
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0); }
|
||||
.panel { margin-top:24px; padding:clamp(24px,5vw,48px); border-top:1px solid var(--pink); border-bottom:1px solid var(--line); background:linear-gradient(120deg,rgba(34,14,57,.97),rgba(10,4,18,.97)); box-shadow:16px 16px rgba(111,44,255,.1); }
|
||||
.panel h1 { margin:.2em 0 .6em; font-size:clamp(26px,5vw,48px); }
|
||||
dl { margin:28px 0 0; border-top:1px solid var(--line); }
|
||||
dl div { display:grid; grid-template-columns:140px 1fr; gap:20px; padding:12px 0; border-bottom:1px solid rgba(157,92,255,.16); }
|
||||
dt { color:var(--muted); font:700 9px/1.5 ui-monospace,monospace; letter-spacing:.14em; }
|
||||
dd { margin:0; color:var(--cyan); font:700 11px/1.5 ui-monospace,monospace; }
|
||||
.hidden { display:none!important; }
|
||||
.meter { height:6px; margin-top:22px; background:#090310; }
|
||||
.meter span { display:block; width:0; height:100%; background:linear-gradient(90deg,var(--pink),var(--violet),var(--cyan)); transition:width .2s; }
|
||||
.file-heading { display:flex; align-items:end; justify-content:space-between; gap:24px; }
|
||||
button { padding:12px 18px; border:0; color:#09020f; background:linear-gradient(90deg,var(--pink),var(--violet)); box-shadow:5px 5px rgba(85,231,255,.14); font-weight:800; text-transform:uppercase; cursor:pointer; }
|
||||
button:hover { background:linear-gradient(90deg,var(--acid),var(--cyan)); }
|
||||
.file-details { margin-top:20px; }
|
||||
.file-details dd { overflow-wrap:anywhere; }
|
||||
.file-details code { color:var(--acid); font:inherit; letter-spacing:.025em; }
|
||||
.preview { margin-top:28px; }
|
||||
.preview img { display:block; max-width:100%; max-height:72vh; margin:auto; object-fit:contain; }
|
||||
.preview audio { display:block; width:100%; }
|
||||
.preview video { display:block; width:100%; max-height:72vh; margin:auto; background:#000; object-fit:contain; }
|
||||
.text-preview { overflow:auto; max-height:68vh; padding:22px; border:1px solid var(--line); color:#d9cee7; background:#08020e; font:13px/1.65 ui-monospace,monospace; white-space:pre-wrap; word-break:break-word; }
|
||||
.text-preview .heading { color:var(--pink); font-weight:800; }
|
||||
.text-preview .quote { color:var(--cyan); }
|
||||
.text-preview .list { color:var(--acid); }
|
||||
.text-preview .code { color:#c69cff; }
|
||||
.error { border-top-color:#ff487f; }
|
||||
.error .status { color:#ff78b4; }
|
||||
body.embed main { width:100%; padding:0; }
|
||||
body.embed .login-hero,body.embed #idle { display:none; }
|
||||
body.embed .panel { margin:0; padding:0; box-shadow:none; border:0; background:transparent; }
|
||||
body.embed .file-heading,body.embed .file-details { display:none; }
|
||||
body.embed .preview { margin:0; }
|
||||
@keyframes blink { 50% { opacity:0; } }
|
||||
@media(max-width:760px){main{width:min(100% - 24px,560px);padding-top:28px}.login-hero{grid-template-columns:1fr;box-shadow:10px 10px 0 rgba(72,24,122,.12)}.login-portrait,.login-portrait img{min-height:300px;max-height:380px}.login-portrait{border-right:0;border-bottom:1px solid var(--line)}.login-intro{padding:42px 24px 36px}.login-intro h1{font-size:clamp(40px,13vw,68px)}.terminal{margin-left:14px;margin-right:14px}.terminal-body{overflow-wrap:anywhere}.file-heading{display:block}.file-heading button{margin-top:12px}dl div{grid-template-columns:100px 1fr}.panel{box-shadow:8px 8px rgba(111,44,255,.1)}}
|
||||
@media(prefers-reduced-motion:reduce){.cursor{animation:none}}
|
||||
Reference in New Issue
Block a user