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

View File

@@ -19,6 +19,14 @@ from flask.views import MethodView
from app.api import bp
from app.config import VERSION
from app.database import check_content_hash, get_db, hash_password, verify_password
from app.metrics import (
record_dedup,
record_paste_accessed,
record_paste_created,
record_paste_deleted,
record_pow,
record_rate_limit,
)
if TYPE_CHECKING:
from sqlite3 import Row
@@ -246,6 +254,16 @@ def prefixed_url(path: str) -> str:
return f"{url_prefix()}{path}"
def paste_url(paste_id: str) -> str:
"""Generate URL for paste metadata endpoint."""
return prefixed_url(f"/{paste_id}")
def paste_raw_url(paste_id: str) -> str:
"""Generate URL for raw paste content endpoint."""
return prefixed_url(f"/{paste_id}/raw")
def base_url() -> str:
"""Detect full base URL from request headers."""
scheme = (
@@ -257,6 +275,59 @@ def base_url() -> str:
return f"{scheme}://{host}{url_prefix()}"
# ─────────────────────────────────────────────────────────────────────────────
# Response Builders
# ─────────────────────────────────────────────────────────────────────────────
def build_paste_metadata(
paste_id: str,
mime_type: str,
size: int,
created_at: int,
*,
owner: str | None = None,
burn_after_read: bool = False,
expires_at: int | None = None,
password_protected: bool = False,
include_owner: bool = False,
last_accessed: int | None = None,
) -> dict[str, Any]:
"""Build standardized paste metadata response dict.
Args:
paste_id: Paste identifier
mime_type: Content MIME type
size: Content size in bytes
created_at: Creation timestamp
owner: Owner fingerprint (included only if include_owner=True)
burn_after_read: Whether paste is burn-after-read
expires_at: Expiration timestamp
password_protected: Whether paste has password
include_owner: Whether to include owner in response
last_accessed: Last access timestamp (optional)
"""
data: dict[str, Any] = {
"id": paste_id,
"mime_type": mime_type,
"size": size,
"created_at": created_at,
"url": paste_url(paste_id),
"raw": paste_raw_url(paste_id),
}
if last_accessed is not None:
data["last_accessed"] = last_accessed
if include_owner and owner:
data["owner"] = owner
if burn_after_read:
data["burn_after_read"] = True
if expires_at:
data["expires_at"] = expires_at
if password_protected:
data["password_protected"] = True
return data
# ─────────────────────────────────────────────────────────────────────────────
# Validation Helpers (used within views)
# ─────────────────────────────────────────────────────────────────────────────
@@ -703,6 +774,17 @@ class IndexView(MethodView):
current_app.logger.warning(
"Rate limit exceeded: ip=%s trusted=%s", client_ip, bool(trusted_client)
)
# Audit log rate limit event
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.RATE_LIMIT,
AuditOutcome.BLOCKED,
client_id=owner,
client_ip=client_ip,
)
record_rate_limit("blocked")
response = error_response(
"Rate limit exceeded",
429,
@@ -729,8 +811,22 @@ class IndexView(MethodView):
current_app.logger.warning(
"PoW verification failed: %s from=%s", err, request.remote_addr
)
# Audit log PoW failure
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.POW_FAILURE,
AuditOutcome.FAILURE,
client_id=owner,
client_ip=client_ip,
details={"error": err},
)
record_pow("failure")
return error_response(f"Proof-of-work failed: {err}", 400)
record_pow("success")
# Size limits (only trusted clients get elevated limits)
content_size = len(content)
max_size = (
@@ -807,6 +903,18 @@ class IndexView(MethodView):
dedup_count,
request.remote_addr,
)
# Audit log dedup block
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.DEDUP_BLOCK,
AuditOutcome.BLOCKED,
client_id=owner,
client_ip=client_ip,
details={"hash": content_hash[:16], "count": dedup_count},
)
record_dedup("blocked")
return error_response(
"Duplicate content rate limit exceeded",
429,
@@ -814,6 +922,8 @@ class IndexView(MethodView):
window_seconds=window,
)
record_dedup("allowed")
# Parse optional headers
burn_header = request.headers.get("X-Burn-After-Read", "").strip().lower()
burn_after_read = burn_header in ("true", "1", "yes")
@@ -883,11 +993,11 @@ class IndexView(MethodView):
)
db.commit()
# Build response
# Build response (creation response is intentionally different - no size)
response_data: dict[str, Any] = {
"id": paste_id,
"url": f"/{paste_id}",
"raw": f"/{paste_id}/raw",
"url": paste_url(paste_id),
"raw": paste_raw_url(paste_id),
"mime_type": mime_type,
"created_at": now,
}
@@ -903,6 +1013,21 @@ class IndexView(MethodView):
# Record successful paste for anti-flood tracking
record_antiflood_request()
# Audit log paste creation
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.PASTE_CREATE,
AuditOutcome.SUCCESS,
paste_id=paste_id,
client_id=owner,
client_ip=client_ip,
details={"size": content_size, "mime_type": mime_type},
)
record_paste_created("authenticated" if owner else "anonymous", "success")
return json_response(response_data, 201)
@@ -1135,21 +1260,17 @@ class PasteView(MethodView):
row: Row = g.paste
g.db.commit()
response_data: dict[str, Any] = {
"id": row["id"],
"mime_type": row["mime_type"],
"size": row["size"],
"created_at": row["created_at"],
"raw": f"/{paste_id}/raw",
}
if row["burn_after_read"]:
response_data["burn_after_read"] = True
if row["expires_at"]:
response_data["expires_at"] = row["expires_at"]
if row["password_hash"]:
response_data["password_protected"] = True
return json_response(response_data)
return json_response(
build_paste_metadata(
paste_id=row["id"],
mime_type=row["mime_type"],
size=row["size"],
created_at=row["created_at"],
burn_after_read=bool(row["burn_after_read"]),
expires_at=row["expires_at"],
password_protected=bool(row["password_hash"]),
)
)
def head(self, paste_id: str) -> Response:
"""Return paste metadata headers only."""
@@ -1254,6 +1375,19 @@ class PasteView(MethodView):
db.execute(update_sql, update_params)
db.commit()
# Audit log paste update
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.PASTE_UPDATE,
AuditOutcome.SUCCESS,
paste_id=paste_id,
client_id=g.client_id,
client_ip=get_client_ip(),
details={"fields": [f.split(" = ")[0] for f in update_fields]},
)
# Fetch updated paste for response
updated = db.execute(
"""SELECT id, mime_type, length(content) as size, expires_at,
@@ -1296,6 +1430,24 @@ class PasteRawView(MethodView):
db.commit()
# Audit log paste access
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.PASTE_ACCESS,
AuditOutcome.SUCCESS,
paste_id=paste_id,
client_id=get_client_fingerprint(),
client_ip=get_client_ip(),
details={"burn": bool(burn_after_read)},
)
record_paste_accessed(
"authenticated" if get_client_fingerprint() else "anonymous",
bool(burn_after_read),
)
response = Response(row["content"], mimetype=row["mime_type"])
if row["mime_type"].startswith(("image/", "text/")):
response.headers["Content-Disposition"] = "inline"
@@ -1350,6 +1502,20 @@ class PasteDeleteView(MethodView):
db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,))
db.commit()
# Audit log paste deletion
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.PASTE_DELETE,
AuditOutcome.SUCCESS,
paste_id=paste_id,
client_id=g.client_id,
client_ip=get_client_ip(),
)
record_paste_deleted("authenticated", "success")
return json_response({"message": "Paste deleted"})
@@ -1457,27 +1623,21 @@ class PastesListView(MethodView):
if type_filter:
rows = [r for r in rows if fnmatch.fnmatch(r["mime_type"], type_filter)]
pastes = []
for row in rows:
paste: dict[str, Any] = {
"id": row["id"],
"mime_type": row["mime_type"],
"size": row["size"],
"created_at": row["created_at"],
"last_accessed": row["last_accessed"],
"url": f"/{row['id']}",
"raw": f"/{row['id']}/raw",
}
# Include owner for admin view
if show_all and row["owner"]:
paste["owner"] = row["owner"]
if row["burn_after_read"]:
paste["burn_after_read"] = True
if row["expires_at"]:
paste["expires_at"] = row["expires_at"]
if row["password_protected"]:
paste["password_protected"] = True
pastes.append(paste)
pastes = [
build_paste_metadata(
paste_id=row["id"],
mime_type=row["mime_type"],
size=row["size"],
created_at=row["created_at"],
owner=row["owner"],
burn_after_read=bool(row["burn_after_read"]),
expires_at=row["expires_at"],
password_protected=bool(row["password_protected"]),
include_owner=show_all,
last_accessed=row["last_accessed"],
)
for row in rows
]
response_data: dict[str, Any] = {
"pastes": pastes,
@@ -1780,6 +1940,79 @@ class PKIRevokeView(MethodView):
return json_response({"message": "Certificate revoked", "serial": serial})
# ─────────────────────────────────────────────────────────────────────────────
# Audit Log View
# ─────────────────────────────────────────────────────────────────────────────
class AuditLogView(MethodView):
"""Audit log query endpoint (admin only)."""
def get(self) -> Response:
"""Query audit log with filters.
Query parameters:
- event_type: Filter by event type
- client_id: Filter by client fingerprint
- paste_id: Filter by paste ID
- outcome: Filter by outcome (success, failure, blocked)
- since: Filter by timestamp >= since
- until: Filter by timestamp <= until
- limit: Maximum results (default 100, max 500)
- offset: Pagination offset
"""
if err := require_auth():
return err
if not is_admin():
return error_response("Admin access required", 403)
from app.audit import query_audit_log
# Parse query parameters
event_type = request.args.get("event_type", "").strip() or None
client_id = request.args.get("client_id", "").strip() or None
paste_id = request.args.get("paste_id", "").strip() or None
outcome = request.args.get("outcome", "").strip() or None
try:
since = int(request.args.get("since", 0)) or None
except (ValueError, TypeError):
since = None
try:
until = int(request.args.get("until", 0)) or None
except (ValueError, TypeError):
until = None
try:
limit = min(int(request.args.get("limit", 100)), 500)
except (ValueError, TypeError):
limit = 100
try:
offset = max(int(request.args.get("offset", 0)), 0)
except (ValueError, TypeError):
offset = 0
entries, total = query_audit_log(
event_type=event_type,
client_id=client_id,
paste_id=paste_id,
outcome=outcome,
since=since,
until=until,
limit=limit,
offset=offset,
)
return json_response(
{
"entries": entries,
"count": len(entries),
"total": total,
"limit": limit,
"offset": offset,
}
)
# ─────────────────────────────────────────────────────────────────────────────
# Route Registration
# ─────────────────────────────────────────────────────────────────────────────
@@ -1817,3 +2050,6 @@ bp.add_url_rule("/pki/certs", view_func=PKICertsView.as_view("pki_certs"))
bp.add_url_rule(
"/pki/revoke/<serial>", view_func=PKIRevokeView.as_view("pki_revoke"), methods=["POST"]
)
# Audit log endpoint (admin only)
bp.add_url_rule("/audit", view_func=AuditLogView.as_view("audit_log"))