Sanitized public release v1.0.3
Source-Tag: v1.0.3 Manifest-SHA256: b7b09bf48f9e092ed8ad82f99c0319d6c3837d0a1549bd0cc4a374276c0f3896
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user