Source-Tag: v1.0.3 Manifest-SHA256: b7b09bf48f9e092ed8ad82f99c0319d6c3837d0a1549bd0cc4a374276c0f3896
614 lines
24 KiB
Python
614 lines
24 KiB
Python
#!/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()
|