forked from claw/flaskpaste
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:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user