forked from claw/flaskpaste
add CLI enhancements and scheduled cleanup
CLI commands: - list: show user's pastes with pagination - search: filter by type (glob), after/before timestamps - update: modify content, password, or extend expiry - export: save pastes to directory with optional decryption API changes: - PUT /<id>: update paste content and metadata - GET /pastes: add type, after, before query params Scheduled tasks: - Thread-safe cleanup with per-task intervals - Activate cleanup_expired_hashes (15min) - Activate cleanup_rate_limits (5min) Tests: 205 passing
This commit is contained in:
481
fpaste
481
fpaste
@@ -385,6 +385,425 @@ def cmd_info(args, config):
|
||||
die("failed to connect to server")
|
||||
|
||||
|
||||
def format_size(size):
|
||||
"""Format byte size as human-readable string."""
|
||||
if size < 1024:
|
||||
return f"{size}B"
|
||||
elif size < 1024 * 1024:
|
||||
return f"{size / 1024:.1f}K"
|
||||
else:
|
||||
return f"{size / (1024 * 1024):.1f}M"
|
||||
|
||||
|
||||
def format_timestamp(ts):
|
||||
"""Format Unix timestamp as human-readable date."""
|
||||
from datetime import datetime
|
||||
|
||||
dt = datetime.fromtimestamp(ts, tz=UTC)
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def cmd_list(args, config):
|
||||
"""List user's pastes."""
|
||||
if not config["cert_sha1"]:
|
||||
die("authentication required (set FLASKPASTE_CERT_SHA1)")
|
||||
|
||||
base = config["server"].rstrip("/")
|
||||
params = []
|
||||
if args.limit:
|
||||
params.append(f"limit={args.limit}")
|
||||
if args.offset:
|
||||
params.append(f"offset={args.offset}")
|
||||
|
||||
url = f"{base}/pastes"
|
||||
if params:
|
||||
url += "?" + "&".join(params)
|
||||
|
||||
headers = {"X-SSL-Client-SHA1": config["cert_sha1"]}
|
||||
status, body, _ = request(url, headers=headers, ssl_context=config.get("ssl_context"))
|
||||
|
||||
if status == 401:
|
||||
die("authentication failed")
|
||||
elif status != 200:
|
||||
die(f"failed to list pastes ({status})")
|
||||
|
||||
data = json.loads(body)
|
||||
pastes = data.get("pastes", [])
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(data, indent=2))
|
||||
return
|
||||
|
||||
if not pastes:
|
||||
print("no pastes found")
|
||||
return
|
||||
|
||||
# Print header
|
||||
print(f"{'ID':<12} {'TYPE':<16} {'SIZE':>6} {'CREATED':<16} FLAGS")
|
||||
|
||||
for p in pastes:
|
||||
paste_id = p["id"]
|
||||
mime_type = p.get("mime_type", "unknown")[:16]
|
||||
size = format_size(p.get("size", 0))
|
||||
created = format_timestamp(p.get("created_at", 0))
|
||||
|
||||
flags = []
|
||||
if p.get("burn_after_read"):
|
||||
flags.append("burn")
|
||||
if p.get("password_protected"):
|
||||
flags.append("pass")
|
||||
if p.get("expires_at"):
|
||||
flags.append("exp")
|
||||
|
||||
flag_str = " ".join(flags)
|
||||
print(f"{paste_id:<12} {mime_type:<16} {size:>6} {created:<16} {flag_str}")
|
||||
|
||||
# Print summary
|
||||
print(f"\n{data.get('count', 0)} of {data.get('total', 0)} pastes shown")
|
||||
|
||||
|
||||
def parse_date(date_str):
|
||||
"""Parse date string to Unix timestamp."""
|
||||
from datetime import datetime
|
||||
|
||||
if not date_str:
|
||||
return 0
|
||||
|
||||
# Try various formats
|
||||
formats = [
|
||||
"%Y-%m-%d",
|
||||
"%Y-%m-%d %H:%M",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%SZ",
|
||||
]
|
||||
for fmt in formats:
|
||||
try:
|
||||
dt = datetime.strptime(date_str, fmt)
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return int(dt.timestamp())
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Try as Unix timestamp
|
||||
try:
|
||||
return int(date_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
die(f"invalid date format: {date_str}")
|
||||
|
||||
|
||||
def cmd_search(args, config):
|
||||
"""Search user's pastes."""
|
||||
if not config["cert_sha1"]:
|
||||
die("authentication required (set FLASKPASTE_CERT_SHA1)")
|
||||
|
||||
base = config["server"].rstrip("/")
|
||||
params = []
|
||||
|
||||
if args.type:
|
||||
params.append(f"type={args.type}")
|
||||
if args.after:
|
||||
ts = parse_date(args.after)
|
||||
params.append(f"after={ts}")
|
||||
if args.before:
|
||||
ts = parse_date(args.before)
|
||||
params.append(f"before={ts}")
|
||||
if args.limit:
|
||||
params.append(f"limit={args.limit}")
|
||||
|
||||
url = f"{base}/pastes"
|
||||
if params:
|
||||
url += "?" + "&".join(params)
|
||||
|
||||
headers = {"X-SSL-Client-SHA1": config["cert_sha1"]}
|
||||
status, body, _ = request(url, headers=headers, ssl_context=config.get("ssl_context"))
|
||||
|
||||
if status == 401:
|
||||
die("authentication failed")
|
||||
elif status != 200:
|
||||
die(f"failed to search pastes ({status})")
|
||||
|
||||
data = json.loads(body)
|
||||
pastes = data.get("pastes", [])
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(data, indent=2))
|
||||
return
|
||||
|
||||
if not pastes:
|
||||
print("no matching pastes found")
|
||||
return
|
||||
|
||||
# Print header
|
||||
print(f"{'ID':<12} {'TYPE':<16} {'SIZE':>6} {'CREATED':<16} FLAGS")
|
||||
|
||||
for p in pastes:
|
||||
paste_id = p["id"]
|
||||
mime_type = p.get("mime_type", "unknown")[:16]
|
||||
size = format_size(p.get("size", 0))
|
||||
created = format_timestamp(p.get("created_at", 0))
|
||||
|
||||
flags = []
|
||||
if p.get("burn_after_read"):
|
||||
flags.append("burn")
|
||||
if p.get("password_protected"):
|
||||
flags.append("pass")
|
||||
if p.get("expires_at"):
|
||||
flags.append("exp")
|
||||
|
||||
flag_str = " ".join(flags)
|
||||
print(f"{paste_id:<12} {mime_type:<16} {size:>6} {created:<16} {flag_str}")
|
||||
|
||||
# Print summary
|
||||
print(f"\n{data.get('count', 0)} matching pastes found")
|
||||
|
||||
|
||||
def cmd_update(args, config):
|
||||
"""Update an existing paste."""
|
||||
if not config["cert_sha1"]:
|
||||
die("authentication required (set FLASKPASTE_CERT_SHA1)")
|
||||
|
||||
paste_id = args.id.split("/")[-1] # Handle full URLs
|
||||
if "#" in paste_id:
|
||||
paste_id = paste_id.split("#")[0] # Remove key fragment
|
||||
|
||||
base = config["server"].rstrip("/")
|
||||
url = f"{base}/{paste_id}"
|
||||
|
||||
headers = {"X-SSL-Client-SHA1": config["cert_sha1"]}
|
||||
content = None
|
||||
|
||||
# Read content from file if provided
|
||||
if args.file:
|
||||
if args.file == "-":
|
||||
content = sys.stdin.buffer.read()
|
||||
else:
|
||||
path = Path(args.file)
|
||||
if not path.exists():
|
||||
die(f"file not found: {args.file}")
|
||||
content = path.read_bytes()
|
||||
|
||||
if not content:
|
||||
die("empty content")
|
||||
|
||||
# Encrypt if requested (default is to encrypt)
|
||||
if not getattr(args, "no_encrypt", False):
|
||||
if not HAS_CRYPTO:
|
||||
die("encryption requires 'cryptography' package (use -E to disable)")
|
||||
if not args.quiet:
|
||||
print("encrypting...", end="", file=sys.stderr)
|
||||
content, encryption_key = encrypt_content(content)
|
||||
if not args.quiet:
|
||||
print(" done", file=sys.stderr)
|
||||
else:
|
||||
encryption_key = None
|
||||
|
||||
# Set metadata update headers
|
||||
if args.password:
|
||||
headers["X-Paste-Password"] = args.password
|
||||
if args.remove_password:
|
||||
headers["X-Remove-Password"] = "true"
|
||||
if args.expiry:
|
||||
headers["X-Extend-Expiry"] = str(args.expiry)
|
||||
|
||||
# Make request
|
||||
status, body, _ = request(
|
||||
url, method="PUT", data=content, headers=headers, ssl_context=config.get("ssl_context")
|
||||
)
|
||||
|
||||
if status == 200:
|
||||
data = json.loads(body)
|
||||
if args.quiet:
|
||||
print(paste_id)
|
||||
else:
|
||||
print(f"updated: {paste_id}")
|
||||
print(f" size: {data.get('size', 'unknown')}")
|
||||
print(f" type: {data.get('mime_type', 'unknown')}")
|
||||
if data.get("expires_at"):
|
||||
print(f" expires: {data.get('expires_at')}")
|
||||
if data.get("password_protected"):
|
||||
print(" password: protected")
|
||||
|
||||
# Show new encryption key if content was updated and encrypted
|
||||
if content and "encryption_key" in dir() and encryption_key:
|
||||
key_fragment = "#" + encode_key(encryption_key)
|
||||
print(f" key: {base}/{paste_id}{key_fragment}")
|
||||
elif status == 400:
|
||||
try:
|
||||
err = json.loads(body).get("error", "bad request")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
err = "bad request"
|
||||
die(err)
|
||||
elif status == 401:
|
||||
die("authentication failed")
|
||||
elif status == 403:
|
||||
die("permission denied (not owner)")
|
||||
elif status == 404:
|
||||
die(f"not found: {paste_id}")
|
||||
else:
|
||||
die(f"update failed ({status})")
|
||||
|
||||
|
||||
def cmd_export(args, config):
|
||||
"""Export user's pastes to a directory."""
|
||||
if not config["cert_sha1"]:
|
||||
die("authentication required (set FLASKPASTE_CERT_SHA1)")
|
||||
|
||||
base = config["server"].rstrip("/")
|
||||
out_dir = Path(args.output) if args.output else Path("fpaste-export")
|
||||
|
||||
# Create output directory
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load key file if provided
|
||||
keys = {}
|
||||
if args.keyfile:
|
||||
keyfile_path = Path(args.keyfile)
|
||||
if not keyfile_path.exists():
|
||||
die(f"key file not found: {args.keyfile}")
|
||||
for line in keyfile_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
paste_id, key_encoded = line.split("=", 1)
|
||||
keys[paste_id.strip()] = key_encoded.strip()
|
||||
|
||||
# Fetch paste list
|
||||
headers = {"X-SSL-Client-SHA1": config["cert_sha1"]}
|
||||
url = f"{base}/pastes?limit=1000" # Fetch all pastes
|
||||
status, body, _ = request(url, headers=headers, ssl_context=config.get("ssl_context"))
|
||||
|
||||
if status == 401:
|
||||
die("authentication failed")
|
||||
elif status != 200:
|
||||
die(f"failed to list pastes ({status})")
|
||||
|
||||
data = json.loads(body)
|
||||
pastes = data.get("pastes", [])
|
||||
|
||||
if not pastes:
|
||||
print("no pastes to export")
|
||||
return
|
||||
|
||||
# Export each paste
|
||||
exported = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
manifest = []
|
||||
|
||||
for p in pastes:
|
||||
paste_id = p["id"]
|
||||
mime_type = p.get("mime_type", "application/octet-stream")
|
||||
|
||||
if not args.quiet:
|
||||
print(f"exporting {paste_id}...", end=" ", file=sys.stderr)
|
||||
|
||||
# Skip burn-after-read pastes
|
||||
if p.get("burn_after_read"):
|
||||
if not args.quiet:
|
||||
print("skipped (burn-after-read)", file=sys.stderr)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Fetch raw content
|
||||
raw_url = f"{base}/{paste_id}/raw"
|
||||
req_headers = dict(headers)
|
||||
if p.get("password_protected"):
|
||||
if not args.quiet:
|
||||
print("skipped (password-protected)", file=sys.stderr)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
ssl_ctx = config.get("ssl_context")
|
||||
status, content, _ = request(raw_url, headers=req_headers, ssl_context=ssl_ctx)
|
||||
|
||||
if status != 200:
|
||||
if not args.quiet:
|
||||
print(f"error ({status})", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
# Decrypt if key available
|
||||
decrypted = False
|
||||
if paste_id in keys:
|
||||
try:
|
||||
key = decode_key(keys[paste_id])
|
||||
content = decrypt_content(content, key)
|
||||
decrypted = True
|
||||
except SystemExit:
|
||||
# Decryption failed, keep encrypted content
|
||||
if not args.quiet:
|
||||
print("decryption failed, keeping encrypted", file=sys.stderr, end=" ")
|
||||
|
||||
# Determine file extension from MIME type
|
||||
ext = get_extension_for_mime(mime_type)
|
||||
filename = f"{paste_id}{ext}"
|
||||
filepath = out_dir / filename
|
||||
|
||||
# Write content
|
||||
filepath.write_bytes(content)
|
||||
|
||||
# Add to manifest
|
||||
manifest.append(
|
||||
{
|
||||
"id": paste_id,
|
||||
"filename": filename,
|
||||
"mime_type": mime_type,
|
||||
"size": len(content),
|
||||
"created_at": p.get("created_at"),
|
||||
"decrypted": decrypted,
|
||||
"encrypted": paste_id in keys and not decrypted,
|
||||
}
|
||||
)
|
||||
|
||||
if not args.quiet:
|
||||
status_msg = "decrypted" if decrypted else ("encrypted" if paste_id in keys else "ok")
|
||||
print(status_msg, file=sys.stderr)
|
||||
|
||||
exported += 1
|
||||
|
||||
# Write manifest
|
||||
if args.manifest:
|
||||
manifest_path = out_dir / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2))
|
||||
if not args.quiet:
|
||||
print(f"manifest: {manifest_path}", file=sys.stderr)
|
||||
|
||||
# Summary
|
||||
print(f"\nexported: {exported}, skipped: {skipped}, errors: {errors}")
|
||||
print(f"output: {out_dir}")
|
||||
|
||||
|
||||
def get_extension_for_mime(mime_type):
|
||||
"""Get file extension for MIME type."""
|
||||
mime_map = {
|
||||
"text/plain": ".txt",
|
||||
"text/html": ".html",
|
||||
"text/css": ".css",
|
||||
"text/javascript": ".js",
|
||||
"text/markdown": ".md",
|
||||
"text/x-python": ".py",
|
||||
"application/json": ".json",
|
||||
"application/xml": ".xml",
|
||||
"application/javascript": ".js",
|
||||
"application/octet-stream": ".bin",
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
"application/pdf": ".pdf",
|
||||
"application/zip": ".zip",
|
||||
"application/gzip": ".gz",
|
||||
"application/x-tar": ".tar",
|
||||
}
|
||||
return mime_map.get(mime_type, ".bin")
|
||||
|
||||
|
||||
def cmd_pki_status(args, config):
|
||||
"""Show PKI status and CA information."""
|
||||
url = config["server"].rstrip("/") + "/pki"
|
||||
@@ -752,7 +1171,28 @@ def is_file_path(arg):
|
||||
def main():
|
||||
# Pre-process arguments: if first positional looks like a file, insert "create"
|
||||
args_to_parse = sys.argv[1:]
|
||||
commands = {"create", "c", "new", "get", "g", "delete", "d", "rm", "info", "i", "cert", "pki"}
|
||||
commands = {
|
||||
"create",
|
||||
"c",
|
||||
"new",
|
||||
"get",
|
||||
"g",
|
||||
"delete",
|
||||
"d",
|
||||
"rm",
|
||||
"info",
|
||||
"i",
|
||||
"list",
|
||||
"ls",
|
||||
"search",
|
||||
"s",
|
||||
"find",
|
||||
"update",
|
||||
"u",
|
||||
"export",
|
||||
"cert",
|
||||
"pki",
|
||||
}
|
||||
|
||||
# Find insertion point for "create" command
|
||||
insert_pos = 0
|
||||
@@ -823,6 +1263,37 @@ def main():
|
||||
# info
|
||||
subparsers.add_parser("info", aliases=["i"], help="show server info")
|
||||
|
||||
# list
|
||||
p_list = subparsers.add_parser("list", aliases=["ls"], help="list your pastes")
|
||||
p_list.add_argument("-l", "--limit", type=int, metavar="N", help="max pastes (default: 50)")
|
||||
p_list.add_argument("-o", "--offset", type=int, metavar="N", help="skip first N pastes")
|
||||
p_list.add_argument("--json", action="store_true", help="output as JSON")
|
||||
|
||||
# search
|
||||
p_search = subparsers.add_parser("search", aliases=["s", "find"], help="search your pastes")
|
||||
p_search.add_argument("-t", "--type", metavar="PATTERN", help="filter by MIME type (image/*)")
|
||||
p_search.add_argument("--after", metavar="DATE", help="created after (YYYY-MM-DD or timestamp)")
|
||||
p_search.add_argument("--before", metavar="DATE", help="created before (YYYY-MM-DD)")
|
||||
p_search.add_argument("-l", "--limit", type=int, metavar="N", help="max results (default: 50)")
|
||||
p_search.add_argument("--json", action="store_true", help="output as JSON")
|
||||
|
||||
# update
|
||||
p_update = subparsers.add_parser("update", aliases=["u"], help="update existing paste")
|
||||
p_update.add_argument("id", help="paste ID or URL")
|
||||
p_update.add_argument("file", nargs="?", help="new content (- for stdin)")
|
||||
p_update.add_argument("-E", "--no-encrypt", action="store_true", help="disable encryption")
|
||||
p_update.add_argument("-p", "--password", metavar="PASS", help="set/change password")
|
||||
p_update.add_argument("--remove-password", action="store_true", help="remove password")
|
||||
p_update.add_argument("-x", "--expiry", type=int, metavar="SEC", help="extend expiry (seconds)")
|
||||
p_update.add_argument("-q", "--quiet", action="store_true", help="minimal output")
|
||||
|
||||
# export
|
||||
p_export = subparsers.add_parser("export", help="export all pastes to directory")
|
||||
p_export.add_argument("-o", "--output", metavar="DIR", help="output directory")
|
||||
p_export.add_argument("-k", "--keyfile", metavar="FILE", help="key file (paste_id=key format)")
|
||||
p_export.add_argument("--manifest", action="store_true", help="write manifest.json")
|
||||
p_export.add_argument("-q", "--quiet", action="store_true", help="minimal output")
|
||||
|
||||
# cert
|
||||
p_cert = subparsers.add_parser("cert", help="generate client certificate")
|
||||
p_cert.add_argument("-o", "--output", metavar="DIR", help="output directory")
|
||||
@@ -904,6 +1375,14 @@ def main():
|
||||
cmd_delete(args, config)
|
||||
elif args.command in ("info", "i"):
|
||||
cmd_info(args, config)
|
||||
elif args.command in ("list", "ls"):
|
||||
cmd_list(args, config)
|
||||
elif args.command in ("search", "s", "find"):
|
||||
cmd_search(args, config)
|
||||
elif args.command in ("update", "u"):
|
||||
cmd_update(args, config)
|
||||
elif args.command == "export":
|
||||
cmd_export(args, config)
|
||||
elif args.command == "cert":
|
||||
cmd_cert(args, config)
|
||||
elif args.command == "pki":
|
||||
|
||||
Reference in New Issue
Block a user