feat: add observability and CLI enhancements
Audit logging: - audit_log table with event tracking - app/audit.py module with log_event(), query_audit_log() - GET /audit endpoint (admin only) - configurable retention and cleanup Prometheus metrics: - app/metrics.py with custom counters - paste create/access/delete, rate limit, PoW, dedup metrics - instrumentation in API routes CLI clipboard integration: - fpaste create -C/--clipboard (read from clipboard) - fpaste create --copy-url (copy result URL) - fpaste get -c/--copy (copy content) - cross-platform: xclip, xsel, pbcopy, wl-copy Shell completions: - completions/ directory with bash/zsh/fish scripts - fpaste completion --shell command
This commit is contained in:
287
fpaste
287
fpaste
@@ -8,7 +8,9 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -431,13 +433,76 @@ def print_paste_list(
|
||||
print(f"\n{summary}")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Clipboard integration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Clipboard read commands (tool name, command args)
|
||||
CLIPBOARD_READ_COMMANDS = [
|
||||
("xclip", ["xclip", "-selection", "clipboard", "-o"]),
|
||||
("xsel", ["xsel", "--clipboard", "--output"]),
|
||||
("pbpaste", ["pbpaste"]),
|
||||
("powershell.exe", ["powershell.exe", "-command", "Get-Clipboard"]),
|
||||
("wl-paste", ["wl-paste"]),
|
||||
]
|
||||
|
||||
# Clipboard write commands (tool name, command args)
|
||||
CLIPBOARD_WRITE_COMMANDS = [
|
||||
("xclip", ["xclip", "-selection", "clipboard", "-i"]),
|
||||
("xsel", ["xsel", "--clipboard", "--input"]),
|
||||
("pbcopy", ["pbcopy"]),
|
||||
("clip.exe", ["clip.exe"]),
|
||||
("wl-copy", ["wl-copy"]),
|
||||
]
|
||||
|
||||
|
||||
def find_clipboard_command(commands: list[tuple[str, list[str]]]) -> list[str] | None:
|
||||
"""Find first available clipboard command."""
|
||||
for tool_name, cmd in commands:
|
||||
if shutil.which(tool_name):
|
||||
return cmd
|
||||
return None
|
||||
|
||||
|
||||
def read_clipboard() -> bytes:
|
||||
"""Read content from system clipboard."""
|
||||
cmd = find_clipboard_command(CLIPBOARD_READ_COMMANDS)
|
||||
if not cmd:
|
||||
die("no clipboard tool found (install xclip, xsel, or wl-paste)")
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, check=True)
|
||||
return result.stdout
|
||||
except subprocess.CalledProcessError as e:
|
||||
die(f"clipboard read failed: {e.stderr.decode(errors='replace')}")
|
||||
except FileNotFoundError:
|
||||
die(f"clipboard tool not found: {cmd[0]}")
|
||||
|
||||
|
||||
def write_clipboard(data: bytes) -> None:
|
||||
"""Write content to system clipboard."""
|
||||
cmd = find_clipboard_command(CLIPBOARD_WRITE_COMMANDS)
|
||||
if not cmd:
|
||||
die("no clipboard tool found (install xclip, xsel, or wl-copy)")
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, input=data, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
die(f"clipboard write failed: {e.stderr.decode(errors='replace') if e.stderr else ''}")
|
||||
except FileNotFoundError:
|
||||
die(f"clipboard tool not found: {cmd[0]}")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Content helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def read_content(file_arg: str | None) -> bytes:
|
||||
"""Read content from file or stdin."""
|
||||
def read_content(file_arg: str | None, from_clipboard: bool = False) -> bytes:
|
||||
"""Read content from file, stdin, or clipboard."""
|
||||
if from_clipboard:
|
||||
return read_clipboard()
|
||||
|
||||
if file_arg:
|
||||
if file_arg == "-":
|
||||
return sys.stdin.buffer.read()
|
||||
@@ -447,7 +512,7 @@ def read_content(file_arg: str | None) -> bytes:
|
||||
return path.read_bytes()
|
||||
|
||||
if sys.stdin.isatty():
|
||||
die("no input provided (pipe data or specify file)")
|
||||
die("no input provided (pipe data, specify file, or use -C for clipboard)")
|
||||
return sys.stdin.buffer.read()
|
||||
|
||||
|
||||
@@ -504,7 +569,8 @@ def require_auth(config: Mapping[str, Any]) -> None:
|
||||
|
||||
def cmd_create(args: argparse.Namespace, config: dict[str, Any]) -> None:
|
||||
"""Create a new paste."""
|
||||
content = read_content(args.file)
|
||||
from_clipboard = getattr(args, "clipboard", False)
|
||||
content = read_content(args.file, from_clipboard=from_clipboard)
|
||||
if not content:
|
||||
die("empty content")
|
||||
|
||||
@@ -562,11 +628,19 @@ def cmd_create(args: argparse.Namespace, config: dict[str, Any]) -> None:
|
||||
base_url = config["server"].rstrip("/")
|
||||
|
||||
if args.raw:
|
||||
print(base_url + data["raw"] + key_fragment)
|
||||
result_url = base_url + data["raw"] + key_fragment
|
||||
elif args.quiet:
|
||||
print(data["id"] + key_fragment)
|
||||
result_url = data["id"] + key_fragment
|
||||
else:
|
||||
print(base_url + data["url"] + key_fragment)
|
||||
result_url = base_url + data["url"] + key_fragment
|
||||
|
||||
print(result_url)
|
||||
|
||||
# Copy URL to clipboard if requested
|
||||
if getattr(args, "copy_url", False):
|
||||
write_clipboard(result_url.encode())
|
||||
if not args.quiet:
|
||||
print("(copied to clipboard)", file=sys.stderr)
|
||||
return
|
||||
|
||||
last_error = parse_error(body, body.decode(errors="replace"))
|
||||
@@ -619,7 +693,11 @@ def cmd_get(args: argparse.Namespace, config: dict[str, Any]) -> None:
|
||||
if encryption_key:
|
||||
body = decrypt_content(body, encryption_key)
|
||||
|
||||
if args.output:
|
||||
# Copy to clipboard if requested
|
||||
if getattr(args, "copy", False):
|
||||
write_clipboard(body)
|
||||
print("(copied to clipboard)", file=sys.stderr)
|
||||
elif args.output:
|
||||
Path(args.output).write_bytes(body)
|
||||
print(f"saved: {args.output}", file=sys.stderr)
|
||||
else:
|
||||
@@ -1323,11 +1401,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
p_create.add_argument("-p", "--password", metavar="PASS", help="password protect")
|
||||
p_create.add_argument("-r", "--raw", action="store_true", help="output raw URL")
|
||||
p_create.add_argument("-q", "--quiet", action="store_true", help="output ID only")
|
||||
p_create.add_argument("-C", "--clipboard", action="store_true", help="read from clipboard")
|
||||
p_create.add_argument("--copy-url", action="store_true", help="copy result URL to clipboard")
|
||||
|
||||
# get
|
||||
p_get = subparsers.add_parser("get", aliases=["g"], help="retrieve paste")
|
||||
p_get.add_argument("id", help="paste ID or URL")
|
||||
p_get.add_argument("-o", "--output", help="save to file")
|
||||
p_get.add_argument("-c", "--copy", action="store_true", help="copy content to clipboard")
|
||||
p_get.add_argument("-p", "--password", metavar="PASS", help="password for protected paste")
|
||||
p_get.add_argument("-m", "--meta", action="store_true", help="show metadata only")
|
||||
|
||||
@@ -1419,9 +1500,198 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"--configure", action="store_true", help="update config file (requires -o)"
|
||||
)
|
||||
|
||||
# completion
|
||||
p_completion = subparsers.add_parser("completion", help="generate shell completion")
|
||||
p_completion.add_argument(
|
||||
"--shell", choices=["bash", "zsh", "fish"], default="bash", help="shell type"
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def cmd_completion(args: argparse.Namespace, config: dict[str, Any]) -> None:
|
||||
"""Output shell completion script."""
|
||||
shell = getattr(args, "shell", None) or "bash"
|
||||
|
||||
# Bash completion - full featured
|
||||
bash_completion = """\
|
||||
# Bash completion for fpaste
|
||||
# Install: source this file or copy to /etc/bash_completion.d/fpaste
|
||||
|
||||
_fpaste_completions() {
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
local commands="create c new get g delete d rm info i list ls"
|
||||
commands+=" search s find update u export register cert pki completion"
|
||||
local pki_commands="status issue download dl"
|
||||
|
||||
if [[ $cword -eq 1 ]]; then
|
||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
local cmd="${words[1]}"
|
||||
|
||||
if [[ "$cmd" == "pki" && $cword -eq 2 ]]; then
|
||||
COMPREPLY=($(compgen -W "$pki_commands" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
case "$cmd" in
|
||||
create|c|new)
|
||||
local opts="-E --no-encrypt -b --burn -x --expiry -p --password"
|
||||
opts+=" -r --raw -q --quiet -C --clipboard --copy-url"
|
||||
[[ "$cur" == -* ]] && COMPREPLY=($(compgen -W "$opts" -- "$cur")) || _filedir
|
||||
;;
|
||||
get|g)
|
||||
local opts="-o --output -c --copy -p --password -m --meta"
|
||||
[[ "$cur" == -* ]] && COMPREPLY=($(compgen -W "$opts" -- "$cur"))
|
||||
;;
|
||||
delete|d|rm)
|
||||
[[ "$cur" == -* ]] && COMPREPLY=($(compgen -W "-a --all -c --confirm" -- "$cur"))
|
||||
;;
|
||||
list|ls)
|
||||
local opts="-a --all -l --limit -o --offset --json"
|
||||
[[ "$cur" == -* ]] && COMPREPLY=($(compgen -W "$opts" -- "$cur"))
|
||||
;;
|
||||
search|s|find)
|
||||
local opts="-t --type --after --before -l --limit --json"
|
||||
[[ "$cur" == -* ]] && COMPREPLY=($(compgen -W "$opts" -- "$cur"))
|
||||
;;
|
||||
update|u)
|
||||
local opts="-E --no-encrypt -p --password --remove-password -x --expiry -q --quiet"
|
||||
[[ "$cur" == -* ]] && COMPREPLY=($(compgen -W "$opts" -- "$cur")) || _filedir
|
||||
;;
|
||||
export)
|
||||
local opts="-o --output -k --keyfile --manifest -q --quiet"
|
||||
[[ "$cur" == -* ]] && COMPREPLY=($(compgen -W "$opts" -- "$cur"))
|
||||
;;
|
||||
register)
|
||||
local opts="-n --name -o --output --configure --p12-only -f --force -q --quiet"
|
||||
[[ "$cur" == -* ]] && COMPREPLY=($(compgen -W "$opts" -- "$cur"))
|
||||
;;
|
||||
cert)
|
||||
local opts="-o --output -a --algorithm -b --bits -c --curve"
|
||||
opts+=" -d --days -n --name --password-key --configure -f --force"
|
||||
[[ "$cur" == -* ]] && COMPREPLY=($(compgen -W "$opts" -- "$cur"))
|
||||
;;
|
||||
completion)
|
||||
[[ "$cur" == -* ]] && COMPREPLY=($(compgen -W "--shell" -- "$cur"))
|
||||
[[ "$prev" == "--shell" ]] && COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur"))
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _fpaste_completions fpaste
|
||||
"""
|
||||
|
||||
# Zsh completion - compact format
|
||||
zsh_completion = """\
|
||||
#compdef fpaste
|
||||
_fpaste() {
|
||||
local curcontext="$curcontext" state line
|
||||
typeset -A opt_args
|
||||
_arguments -C '-s[Server]:url:' '--server[Server]:url:' '1: :->cmd' '*:: :->args'
|
||||
case $state in
|
||||
cmd)
|
||||
local cmds=('create:Create paste' 'get:Get paste' 'delete:Delete'
|
||||
'info:Server info' 'list:List pastes' 'search:Search'
|
||||
'update:Update paste' 'export:Export' 'register:Register'
|
||||
'cert:Generate cert' 'pki:PKI ops' 'completion:Completions')
|
||||
_describe -t commands 'commands' cmds
|
||||
;;
|
||||
args)
|
||||
case $line[1] in
|
||||
create|c|new)
|
||||
_arguments '-E[No encrypt]' '-b[Burn]' '-x[Expiry]:sec:' \\
|
||||
'-p[Pass]:p:' '-r[Raw]' '-q[Quiet]' '-C[Clipboard]' \\
|
||||
'--copy-url' '*:file:_files' ;;
|
||||
get|g)
|
||||
_arguments '-o[Out]:f:_files' '-c[Copy]' '-p[Pass]:p:' \\
|
||||
'-m[Meta]' '1:ID:' ;;
|
||||
delete|d|rm) _arguments '-a[All]' '-c[Confirm]:n:' '*:ID:' ;;
|
||||
list|ls) _arguments '-a[All]' '-l[Limit]:n:' '-o[Off]:n:' '--json' ;;
|
||||
search|s|find)
|
||||
_arguments '-t[Type]:p:' '--after:d:' '--before:d:' \\
|
||||
'-l[Limit]:n:' '--json' ;;
|
||||
update|u)
|
||||
_arguments '-E[No encrypt]' '-p[Pass]:p:' '--remove-password' \\
|
||||
'-x[Expiry]:s:' '-q[Quiet]' '1:ID:' '*:file:_files' ;;
|
||||
export)
|
||||
_arguments '-o[Dir]:d:_files -/' '-k[Keys]:f:_files' \\
|
||||
'--manifest' '-q[Quiet]' ;;
|
||||
register)
|
||||
_arguments '-n[Name]:cn:' '-o[Dir]:d:_files -/' '--configure' \\
|
||||
'--p12-only' '-f[Force]' '-q[Quiet]' ;;
|
||||
cert)
|
||||
_arguments '-o[Dir]:d:_files -/' '-a[Algo]:(rsa ec)' \\
|
||||
'-b[Bits]:n:' '-c[Curve]:(secp256r1 secp384r1 secp521r1)' \\
|
||||
'-d[Days]:n:' '-n[Name]:cn:' '--configure' '-f[Force]' ;;
|
||||
pki) (( CURRENT == 2 )) && _describe 'cmd' '(status issue download)' ;;
|
||||
completion) _arguments '--shell:(bash zsh fish)' ;;
|
||||
esac ;;
|
||||
esac
|
||||
}
|
||||
_fpaste "$@"
|
||||
"""
|
||||
|
||||
# Fish completion - compact
|
||||
fish_completion = """\
|
||||
# Fish completion for fpaste
|
||||
complete -c fpaste -f
|
||||
complete -c fpaste -n __fish_use_subcommand -a 'create c new' -d 'Create'
|
||||
complete -c fpaste -n __fish_use_subcommand -a 'get g' -d 'Get paste'
|
||||
complete -c fpaste -n __fish_use_subcommand -a 'delete d rm' -d 'Delete'
|
||||
complete -c fpaste -n __fish_use_subcommand -a 'info i' -d 'Server info'
|
||||
complete -c fpaste -n __fish_use_subcommand -a 'list ls' -d 'List'
|
||||
complete -c fpaste -n __fish_use_subcommand -a 'search s find' -d 'Search'
|
||||
complete -c fpaste -n __fish_use_subcommand -a 'update u' -d 'Update'
|
||||
complete -c fpaste -n __fish_use_subcommand -a 'export' -d 'Export'
|
||||
complete -c fpaste -n __fish_use_subcommand -a 'register' -d 'Register'
|
||||
complete -c fpaste -n __fish_use_subcommand -a 'cert' -d 'Gen cert'
|
||||
complete -c fpaste -n __fish_use_subcommand -a 'pki' -d 'PKI'
|
||||
complete -c fpaste -n __fish_use_subcommand -a 'completion' -d 'Completions'
|
||||
|
||||
set -l cr '__fish_seen_subcommand_from create c new'
|
||||
complete -c fpaste -n $cr -s E -l no-encrypt -d 'No encrypt'
|
||||
complete -c fpaste -n $cr -s b -l burn -d 'Burn'
|
||||
complete -c fpaste -n $cr -s x -l expiry -d 'Expiry' -x
|
||||
complete -c fpaste -n $cr -s p -l password -d 'Password' -x
|
||||
complete -c fpaste -n $cr -s r -l raw -d 'Raw URL'
|
||||
complete -c fpaste -n $cr -s q -l quiet -d 'Quiet'
|
||||
complete -c fpaste -n $cr -s C -l clipboard -d 'Clipboard'
|
||||
complete -c fpaste -n $cr -l copy-url -d 'Copy URL'
|
||||
complete -c fpaste -n $cr -F
|
||||
|
||||
set -l gt '__fish_seen_subcommand_from get g'
|
||||
complete -c fpaste -n $gt -s o -l output -d 'Output' -r
|
||||
complete -c fpaste -n $gt -s c -l copy -d 'Copy'
|
||||
complete -c fpaste -n $gt -s p -l password -d 'Password' -x
|
||||
complete -c fpaste -n $gt -s m -l meta -d 'Metadata'
|
||||
|
||||
set -l dl '__fish_seen_subcommand_from delete d rm'
|
||||
complete -c fpaste -n $dl -s a -l all -d 'All'
|
||||
complete -c fpaste -n $dl -s c -l confirm -d 'Confirm' -x
|
||||
|
||||
set -l ls '__fish_seen_subcommand_from list ls'
|
||||
complete -c fpaste -n $ls -s a -l all -d 'All'
|
||||
complete -c fpaste -n $ls -s l -l limit -d 'Limit' -x
|
||||
complete -c fpaste -n $ls -s o -l offset -d 'Offset' -x
|
||||
complete -c fpaste -n $ls -l json -d 'JSON'
|
||||
|
||||
set -l cp '__fish_seen_subcommand_from completion'
|
||||
complete -c fpaste -n $cp -l shell -d 'Shell' -xa 'bash zsh fish'
|
||||
"""
|
||||
|
||||
completions = {"bash": bash_completion, "zsh": zsh_completion, "fish": fish_completion}
|
||||
|
||||
if shell not in completions:
|
||||
die(f"unsupported shell: {shell} (use: bash, zsh, fish)")
|
||||
|
||||
print(completions[shell])
|
||||
|
||||
|
||||
# Command dispatch table
|
||||
COMMANDS: dict[str, Any] = {
|
||||
"create": cmd_create,
|
||||
@@ -1444,6 +1714,7 @@ COMMANDS: dict[str, Any] = {
|
||||
"export": cmd_export,
|
||||
"register": cmd_register,
|
||||
"cert": cmd_cert,
|
||||
"completion": cmd_completion,
|
||||
}
|
||||
|
||||
PKI_COMMANDS: dict[str, Any] = {
|
||||
|
||||
Reference in New Issue
Block a user