feat: add observability and CLI enhancements
Some checks failed
CI / Lint & Format (push) Failing after 16s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 20s

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:
Username
2025-12-23 22:39:50 +01:00
parent 4d08a4467d
commit 7063f8718e
13 changed files with 2003 additions and 47 deletions

287
fpaste
View File

@@ -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] = {