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:
@@ -190,6 +190,11 @@ def setup_metrics(app: Flask) -> None:
|
|||||||
metrics.info("flaskpaste_info", "FlaskPaste application info", version=VERSION)
|
metrics.info("flaskpaste_info", "FlaskPaste application info", version=VERSION)
|
||||||
|
|
||||||
app.extensions["metrics"] = metrics
|
app.extensions["metrics"] = metrics
|
||||||
|
|
||||||
|
# Setup custom metrics
|
||||||
|
from app.metrics import setup_custom_metrics
|
||||||
|
|
||||||
|
setup_custom_metrics(app)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
app.logger.warning("prometheus_flask_exporter not available, metrics disabled")
|
app.logger.warning("prometheus_flask_exporter not available, metrics disabled")
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ _cleanup_times: dict[str, float] = {
|
|||||||
"pastes": 0.0,
|
"pastes": 0.0,
|
||||||
"hashes": 0.0,
|
"hashes": 0.0,
|
||||||
"rate_limits": 0.0,
|
"rate_limits": 0.0,
|
||||||
|
"audit": 0.0,
|
||||||
}
|
}
|
||||||
_CLEANUP_INTERVALS = {
|
_CLEANUP_INTERVALS = {
|
||||||
"pastes": 3600, # 1 hour
|
"pastes": 3600, # 1 hour
|
||||||
"hashes": 900, # 15 minutes
|
"hashes": 900, # 15 minutes
|
||||||
"rate_limits": 300, # 5 minutes
|
"rate_limits": 300, # 5 minutes
|
||||||
|
"audit": 86400, # 24 hours
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -61,5 +63,15 @@ def run_scheduled_cleanup():
|
|||||||
if count > 0:
|
if count > 0:
|
||||||
current_app.logger.info(f"Cleaned up {count} rate limit entries")
|
current_app.logger.info(f"Cleaned up {count} rate limit entries")
|
||||||
|
|
||||||
|
# Cleanup old audit logs
|
||||||
|
if now - _cleanup_times["audit"] >= _CLEANUP_INTERVALS["audit"]:
|
||||||
|
_cleanup_times["audit"] = now
|
||||||
|
if current_app.config.get("AUDIT_ENABLED", True):
|
||||||
|
from app.audit import cleanup_old_audit_logs
|
||||||
|
|
||||||
|
count = cleanup_old_audit_logs()
|
||||||
|
if count > 0:
|
||||||
|
current_app.logger.info(f"Cleaned up {count} old audit log entries")
|
||||||
|
|
||||||
|
|
||||||
from app.api import routes # noqa: E402, F401
|
from app.api import routes # noqa: E402, F401
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ from flask.views import MethodView
|
|||||||
from app.api import bp
|
from app.api import bp
|
||||||
from app.config import VERSION
|
from app.config import VERSION
|
||||||
from app.database import check_content_hash, get_db, hash_password, verify_password
|
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:
|
if TYPE_CHECKING:
|
||||||
from sqlite3 import Row
|
from sqlite3 import Row
|
||||||
@@ -246,6 +254,16 @@ def prefixed_url(path: str) -> str:
|
|||||||
return f"{url_prefix()}{path}"
|
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:
|
def base_url() -> str:
|
||||||
"""Detect full base URL from request headers."""
|
"""Detect full base URL from request headers."""
|
||||||
scheme = (
|
scheme = (
|
||||||
@@ -257,6 +275,59 @@ def base_url() -> str:
|
|||||||
return f"{scheme}://{host}{url_prefix()}"
|
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)
|
# Validation Helpers (used within views)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -703,6 +774,17 @@ class IndexView(MethodView):
|
|||||||
current_app.logger.warning(
|
current_app.logger.warning(
|
||||||
"Rate limit exceeded: ip=%s trusted=%s", client_ip, bool(trusted_client)
|
"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(
|
response = error_response(
|
||||||
"Rate limit exceeded",
|
"Rate limit exceeded",
|
||||||
429,
|
429,
|
||||||
@@ -729,8 +811,22 @@ class IndexView(MethodView):
|
|||||||
current_app.logger.warning(
|
current_app.logger.warning(
|
||||||
"PoW verification failed: %s from=%s", err, request.remote_addr
|
"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)
|
return error_response(f"Proof-of-work failed: {err}", 400)
|
||||||
|
|
||||||
|
record_pow("success")
|
||||||
|
|
||||||
# Size limits (only trusted clients get elevated limits)
|
# Size limits (only trusted clients get elevated limits)
|
||||||
content_size = len(content)
|
content_size = len(content)
|
||||||
max_size = (
|
max_size = (
|
||||||
@@ -807,6 +903,18 @@ class IndexView(MethodView):
|
|||||||
dedup_count,
|
dedup_count,
|
||||||
request.remote_addr,
|
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(
|
return error_response(
|
||||||
"Duplicate content rate limit exceeded",
|
"Duplicate content rate limit exceeded",
|
||||||
429,
|
429,
|
||||||
@@ -814,6 +922,8 @@ class IndexView(MethodView):
|
|||||||
window_seconds=window,
|
window_seconds=window,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
record_dedup("allowed")
|
||||||
|
|
||||||
# Parse optional headers
|
# Parse optional headers
|
||||||
burn_header = request.headers.get("X-Burn-After-Read", "").strip().lower()
|
burn_header = request.headers.get("X-Burn-After-Read", "").strip().lower()
|
||||||
burn_after_read = burn_header in ("true", "1", "yes")
|
burn_after_read = burn_header in ("true", "1", "yes")
|
||||||
@@ -883,11 +993,11 @@ class IndexView(MethodView):
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Build response
|
# Build response (creation response is intentionally different - no size)
|
||||||
response_data: dict[str, Any] = {
|
response_data: dict[str, Any] = {
|
||||||
"id": paste_id,
|
"id": paste_id,
|
||||||
"url": f"/{paste_id}",
|
"url": paste_url(paste_id),
|
||||||
"raw": f"/{paste_id}/raw",
|
"raw": paste_raw_url(paste_id),
|
||||||
"mime_type": mime_type,
|
"mime_type": mime_type,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
}
|
}
|
||||||
@@ -903,6 +1013,21 @@ class IndexView(MethodView):
|
|||||||
# Record successful paste for anti-flood tracking
|
# Record successful paste for anti-flood tracking
|
||||||
record_antiflood_request()
|
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)
|
return json_response(response_data, 201)
|
||||||
|
|
||||||
|
|
||||||
@@ -1135,21 +1260,17 @@ class PasteView(MethodView):
|
|||||||
row: Row = g.paste
|
row: Row = g.paste
|
||||||
g.db.commit()
|
g.db.commit()
|
||||||
|
|
||||||
response_data: dict[str, Any] = {
|
return json_response(
|
||||||
"id": row["id"],
|
build_paste_metadata(
|
||||||
"mime_type": row["mime_type"],
|
paste_id=row["id"],
|
||||||
"size": row["size"],
|
mime_type=row["mime_type"],
|
||||||
"created_at": row["created_at"],
|
size=row["size"],
|
||||||
"raw": f"/{paste_id}/raw",
|
created_at=row["created_at"],
|
||||||
}
|
burn_after_read=bool(row["burn_after_read"]),
|
||||||
if row["burn_after_read"]:
|
expires_at=row["expires_at"],
|
||||||
response_data["burn_after_read"] = True
|
password_protected=bool(row["password_hash"]),
|
||||||
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)
|
|
||||||
|
|
||||||
def head(self, paste_id: str) -> Response:
|
def head(self, paste_id: str) -> Response:
|
||||||
"""Return paste metadata headers only."""
|
"""Return paste metadata headers only."""
|
||||||
@@ -1254,6 +1375,19 @@ class PasteView(MethodView):
|
|||||||
db.execute(update_sql, update_params)
|
db.execute(update_sql, update_params)
|
||||||
db.commit()
|
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
|
# Fetch updated paste for response
|
||||||
updated = db.execute(
|
updated = db.execute(
|
||||||
"""SELECT id, mime_type, length(content) as size, expires_at,
|
"""SELECT id, mime_type, length(content) as size, expires_at,
|
||||||
@@ -1296,6 +1430,24 @@ class PasteRawView(MethodView):
|
|||||||
|
|
||||||
db.commit()
|
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"])
|
response = Response(row["content"], mimetype=row["mime_type"])
|
||||||
if row["mime_type"].startswith(("image/", "text/")):
|
if row["mime_type"].startswith(("image/", "text/")):
|
||||||
response.headers["Content-Disposition"] = "inline"
|
response.headers["Content-Disposition"] = "inline"
|
||||||
@@ -1350,6 +1502,20 @@ class PasteDeleteView(MethodView):
|
|||||||
db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,))
|
db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,))
|
||||||
db.commit()
|
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"})
|
return json_response({"message": "Paste deleted"})
|
||||||
|
|
||||||
|
|
||||||
@@ -1457,27 +1623,21 @@ class PastesListView(MethodView):
|
|||||||
if type_filter:
|
if type_filter:
|
||||||
rows = [r for r in rows if fnmatch.fnmatch(r["mime_type"], type_filter)]
|
rows = [r for r in rows if fnmatch.fnmatch(r["mime_type"], type_filter)]
|
||||||
|
|
||||||
pastes = []
|
pastes = [
|
||||||
for row in rows:
|
build_paste_metadata(
|
||||||
paste: dict[str, Any] = {
|
paste_id=row["id"],
|
||||||
"id": row["id"],
|
mime_type=row["mime_type"],
|
||||||
"mime_type": row["mime_type"],
|
size=row["size"],
|
||||||
"size": row["size"],
|
created_at=row["created_at"],
|
||||||
"created_at": row["created_at"],
|
owner=row["owner"],
|
||||||
"last_accessed": row["last_accessed"],
|
burn_after_read=bool(row["burn_after_read"]),
|
||||||
"url": f"/{row['id']}",
|
expires_at=row["expires_at"],
|
||||||
"raw": f"/{row['id']}/raw",
|
password_protected=bool(row["password_protected"]),
|
||||||
}
|
include_owner=show_all,
|
||||||
# Include owner for admin view
|
last_accessed=row["last_accessed"],
|
||||||
if show_all and row["owner"]:
|
)
|
||||||
paste["owner"] = row["owner"]
|
for row in rows
|
||||||
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)
|
|
||||||
|
|
||||||
response_data: dict[str, Any] = {
|
response_data: dict[str, Any] = {
|
||||||
"pastes": pastes,
|
"pastes": pastes,
|
||||||
@@ -1780,6 +1940,79 @@ class PKIRevokeView(MethodView):
|
|||||||
return json_response({"message": "Certificate revoked", "serial": serial})
|
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
|
# Route Registration
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -1817,3 +2050,6 @@ bp.add_url_rule("/pki/certs", view_func=PKICertsView.as_view("pki_certs"))
|
|||||||
bp.add_url_rule(
|
bp.add_url_rule(
|
||||||
"/pki/revoke/<serial>", view_func=PKIRevokeView.as_view("pki_revoke"), methods=["POST"]
|
"/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"))
|
||||||
|
|||||||
206
app/audit.py
Normal file
206
app/audit.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""Audit logging for security events."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from flask import current_app, g
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlite3 import Row
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEvent(str, Enum):
|
||||||
|
"""Security-relevant event types for audit logging."""
|
||||||
|
|
||||||
|
PASTE_CREATE = "paste_create"
|
||||||
|
PASTE_ACCESS = "paste_access"
|
||||||
|
PASTE_DELETE = "paste_delete"
|
||||||
|
PASTE_UPDATE = "paste_update"
|
||||||
|
RATE_LIMIT = "rate_limit"
|
||||||
|
POW_FAILURE = "pow_failure"
|
||||||
|
DEDUP_BLOCK = "dedup_block"
|
||||||
|
CERT_ISSUED = "cert_issued"
|
||||||
|
CERT_REVOKED = "cert_revoked"
|
||||||
|
AUTH_FAILURE = "auth_failure"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditOutcome(str, Enum):
|
||||||
|
"""Outcome types for audit events."""
|
||||||
|
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILURE = "failure"
|
||||||
|
BLOCKED = "blocked"
|
||||||
|
|
||||||
|
|
||||||
|
def log_event(
|
||||||
|
event_type: AuditEvent | str,
|
||||||
|
outcome: AuditOutcome | str,
|
||||||
|
*,
|
||||||
|
paste_id: str | None = None,
|
||||||
|
client_id: str | None = None,
|
||||||
|
client_ip: str | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Log a security-relevant event to the audit log.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_type: Type of event (from AuditEvent enum or string)
|
||||||
|
outcome: Result of the event (success, failure, blocked)
|
||||||
|
paste_id: Related paste ID (if applicable)
|
||||||
|
client_id: Client certificate fingerprint (if authenticated)
|
||||||
|
client_ip: Client IP address
|
||||||
|
details: Additional event-specific details (stored as JSON)
|
||||||
|
"""
|
||||||
|
if not current_app.config.get("AUDIT_ENABLED", True):
|
||||||
|
return
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
request_id = getattr(g, "request_id", None)
|
||||||
|
|
||||||
|
# Convert enums to strings
|
||||||
|
event_type_str = event_type.value if isinstance(event_type, AuditEvent) else event_type
|
||||||
|
outcome_str = outcome.value if isinstance(outcome, AuditOutcome) else outcome
|
||||||
|
|
||||||
|
# Serialize details to JSON if provided
|
||||||
|
details_json = json.dumps(details, ensure_ascii=False) if details else None
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO audit_log
|
||||||
|
(timestamp, event_type, client_id, client_ip, paste_id, request_id, outcome, details)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
now,
|
||||||
|
event_type_str,
|
||||||
|
client_id,
|
||||||
|
client_ip,
|
||||||
|
paste_id,
|
||||||
|
request_id,
|
||||||
|
outcome_str,
|
||||||
|
details_json,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def query_audit_log(
|
||||||
|
*,
|
||||||
|
event_type: str | None = None,
|
||||||
|
client_id: str | None = None,
|
||||||
|
paste_id: str | None = None,
|
||||||
|
outcome: str | None = None,
|
||||||
|
since: int | None = None,
|
||||||
|
until: int | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
"""Query audit log with filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
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 to return
|
||||||
|
offset: Pagination offset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (list of audit entries, total count matching filters)
|
||||||
|
"""
|
||||||
|
db = get_db()
|
||||||
|
|
||||||
|
where_clauses: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
|
||||||
|
if event_type:
|
||||||
|
where_clauses.append("event_type = ?")
|
||||||
|
params.append(event_type)
|
||||||
|
if client_id:
|
||||||
|
where_clauses.append("client_id = ?")
|
||||||
|
params.append(client_id)
|
||||||
|
if paste_id:
|
||||||
|
where_clauses.append("paste_id = ?")
|
||||||
|
params.append(paste_id)
|
||||||
|
if outcome:
|
||||||
|
where_clauses.append("outcome = ?")
|
||||||
|
params.append(outcome)
|
||||||
|
if since:
|
||||||
|
where_clauses.append("timestamp >= ?")
|
||||||
|
params.append(since)
|
||||||
|
if until:
|
||||||
|
where_clauses.append("timestamp <= ?")
|
||||||
|
params.append(until)
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_row = db.execute(
|
||||||
|
f"SELECT COUNT(*) as total FROM audit_log WHERE {where_sql}", # noqa: S608
|
||||||
|
params,
|
||||||
|
).fetchone()
|
||||||
|
total = count_row["total"] if count_row else 0
|
||||||
|
|
||||||
|
# Fetch entries
|
||||||
|
rows: list[Row] = db.execute(
|
||||||
|
f"""SELECT id, timestamp, event_type, client_id, client_ip,
|
||||||
|
paste_id, request_id, outcome, details
|
||||||
|
FROM audit_log
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ? OFFSET ?""", # noqa: S608
|
||||||
|
[*params, limit, offset],
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for row in rows:
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"id": row["id"],
|
||||||
|
"timestamp": row["timestamp"],
|
||||||
|
"event_type": row["event_type"],
|
||||||
|
"outcome": row["outcome"],
|
||||||
|
}
|
||||||
|
if row["client_id"]:
|
||||||
|
entry["client_id"] = row["client_id"]
|
||||||
|
if row["client_ip"]:
|
||||||
|
entry["client_ip"] = row["client_ip"]
|
||||||
|
if row["paste_id"]:
|
||||||
|
entry["paste_id"] = row["paste_id"]
|
||||||
|
if row["request_id"]:
|
||||||
|
entry["request_id"] = row["request_id"]
|
||||||
|
if row["details"]:
|
||||||
|
try:
|
||||||
|
entry["details"] = json.loads(row["details"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
entry["details"] = row["details"]
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
return entries, total
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_audit_logs(retention_days: int | None = None) -> int:
|
||||||
|
"""Delete audit log entries older than retention period.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
retention_days: Number of days to keep. If None, uses config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of deleted entries.
|
||||||
|
"""
|
||||||
|
if retention_days is None:
|
||||||
|
retention_days = current_app.config.get("AUDIT_RETENTION_DAYS", 90)
|
||||||
|
|
||||||
|
cutoff = int(time.time()) - (retention_days * 24 * 60 * 60)
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
cursor = db.execute("DELETE FROM audit_log WHERE timestamp < ?", (cutoff,))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return cursor.rowcount
|
||||||
@@ -100,6 +100,11 @@ class Config:
|
|||||||
# Authenticated users get higher limits (multiplier)
|
# Authenticated users get higher limits (multiplier)
|
||||||
RATE_LIMIT_AUTH_MULTIPLIER = int(os.environ.get("FLASKPASTE_RATE_AUTH_MULT", "5"))
|
RATE_LIMIT_AUTH_MULTIPLIER = int(os.environ.get("FLASKPASTE_RATE_AUTH_MULT", "5"))
|
||||||
|
|
||||||
|
# Audit Logging
|
||||||
|
# Track security-relevant events (paste creation, deletion, rate limits, etc.)
|
||||||
|
AUDIT_ENABLED = os.environ.get("FLASKPASTE_AUDIT", "1").lower() in ("1", "true", "yes")
|
||||||
|
AUDIT_RETENTION_DAYS = int(os.environ.get("FLASKPASTE_AUDIT_RETENTION", "90"))
|
||||||
|
|
||||||
# PKI Configuration
|
# PKI Configuration
|
||||||
# Enable PKI endpoints for certificate authority and issuance
|
# Enable PKI endpoints for certificate authority and issuance
|
||||||
PKI_ENABLED = os.environ.get("FLASKPASTE_PKI_ENABLED", "0").lower() in ("1", "true", "yes")
|
PKI_ENABLED = os.environ.get("FLASKPASTE_PKI_ENABLED", "0").lower() in ("1", "true", "yes")
|
||||||
|
|||||||
@@ -73,6 +73,23 @@ CREATE TABLE IF NOT EXISTS issued_certificates (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_certs_fingerprint ON issued_certificates(fingerprint_sha1);
|
CREATE INDEX IF NOT EXISTS idx_certs_fingerprint ON issued_certificates(fingerprint_sha1);
|
||||||
CREATE INDEX IF NOT EXISTS idx_certs_status ON issued_certificates(status);
|
CREATE INDEX IF NOT EXISTS idx_certs_status ON issued_certificates(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_certs_ca_id ON issued_certificates(ca_id);
|
CREATE INDEX IF NOT EXISTS idx_certs_ca_id ON issued_certificates(ca_id);
|
||||||
|
|
||||||
|
-- Audit log for security events
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
client_id TEXT,
|
||||||
|
client_ip TEXT,
|
||||||
|
paste_id TEXT,
|
||||||
|
request_id TEXT,
|
||||||
|
outcome TEXT NOT NULL,
|
||||||
|
details TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_event_type ON audit_log(event_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_client_id ON audit_log(client_id);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Password hashing constants
|
# Password hashing constants
|
||||||
|
|||||||
160
app/metrics.py
Normal file
160
app/metrics.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""Custom Prometheus metrics for FlaskPaste."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
# Metrics are only initialized when not testing
|
||||||
|
_paste_created_counter = None
|
||||||
|
_paste_accessed_counter = None
|
||||||
|
_paste_deleted_counter = None
|
||||||
|
_rate_limit_counter = None
|
||||||
|
_pow_counter = None
|
||||||
|
_dedup_counter = None
|
||||||
|
_request_duration_histogram = None
|
||||||
|
|
||||||
|
|
||||||
|
def setup_custom_metrics(app: Flask) -> None:
|
||||||
|
"""Initialize custom Prometheus metrics.
|
||||||
|
|
||||||
|
Should be called after prometheus_flask_exporter is set up.
|
||||||
|
"""
|
||||||
|
global _paste_created_counter, _paste_accessed_counter, _paste_deleted_counter
|
||||||
|
global _rate_limit_counter, _pow_counter, _dedup_counter, _request_duration_histogram
|
||||||
|
|
||||||
|
if app.config.get("TESTING"):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from prometheus_client import Counter, Histogram
|
||||||
|
except ImportError:
|
||||||
|
app.logger.warning("prometheus_client not available, custom metrics disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
_paste_created_counter = Counter(
|
||||||
|
"flaskpaste_paste_created_total",
|
||||||
|
"Total number of paste creation attempts",
|
||||||
|
["auth_type", "outcome"],
|
||||||
|
)
|
||||||
|
|
||||||
|
_paste_accessed_counter = Counter(
|
||||||
|
"flaskpaste_paste_accessed_total",
|
||||||
|
"Total number of paste accesses",
|
||||||
|
["auth_type", "burn"],
|
||||||
|
)
|
||||||
|
|
||||||
|
_paste_deleted_counter = Counter(
|
||||||
|
"flaskpaste_paste_deleted_total",
|
||||||
|
"Total number of paste deletion attempts",
|
||||||
|
["auth_type", "outcome"],
|
||||||
|
)
|
||||||
|
|
||||||
|
_rate_limit_counter = Counter(
|
||||||
|
"flaskpaste_rate_limit_total",
|
||||||
|
"Total number of rate limit events",
|
||||||
|
["outcome"],
|
||||||
|
)
|
||||||
|
|
||||||
|
_pow_counter = Counter(
|
||||||
|
"flaskpaste_pow_total",
|
||||||
|
"Total number of proof-of-work validations",
|
||||||
|
["outcome"],
|
||||||
|
)
|
||||||
|
|
||||||
|
_dedup_counter = Counter(
|
||||||
|
"flaskpaste_dedup_total",
|
||||||
|
"Total number of content deduplication events",
|
||||||
|
["outcome"],
|
||||||
|
)
|
||||||
|
|
||||||
|
_request_duration_histogram = Histogram(
|
||||||
|
"flaskpaste_request_duration_seconds",
|
||||||
|
"Request duration in seconds",
|
||||||
|
["method", "endpoint", "status"],
|
||||||
|
buckets=(0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.logger.info("Custom Prometheus metrics initialized")
|
||||||
|
|
||||||
|
|
||||||
|
def record_paste_created(auth_type: str, outcome: str) -> None:
|
||||||
|
"""Record a paste creation attempt.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth_type: "authenticated" or "anonymous"
|
||||||
|
outcome: "success", "failure", or "blocked"
|
||||||
|
"""
|
||||||
|
if _paste_created_counter:
|
||||||
|
_paste_created_counter.labels(auth_type=auth_type, outcome=outcome).inc()
|
||||||
|
|
||||||
|
|
||||||
|
def record_paste_accessed(auth_type: str, burn: bool) -> None:
|
||||||
|
"""Record a paste access.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth_type: "authenticated" or "anonymous"
|
||||||
|
burn: True if paste was burn-after-read
|
||||||
|
"""
|
||||||
|
if _paste_accessed_counter:
|
||||||
|
_paste_accessed_counter.labels(auth_type=auth_type, burn=str(burn).lower()).inc()
|
||||||
|
|
||||||
|
|
||||||
|
def record_paste_deleted(auth_type: str, outcome: str) -> None:
|
||||||
|
"""Record a paste deletion attempt.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth_type: "authenticated" or "anonymous"
|
||||||
|
outcome: "success" or "failure"
|
||||||
|
"""
|
||||||
|
if _paste_deleted_counter:
|
||||||
|
_paste_deleted_counter.labels(auth_type=auth_type, outcome=outcome).inc()
|
||||||
|
|
||||||
|
|
||||||
|
def record_rate_limit(outcome: str) -> None:
|
||||||
|
"""Record a rate limit event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
outcome: "allowed" or "blocked"
|
||||||
|
"""
|
||||||
|
if _rate_limit_counter:
|
||||||
|
_rate_limit_counter.labels(outcome=outcome).inc()
|
||||||
|
|
||||||
|
|
||||||
|
def record_pow(outcome: str) -> None:
|
||||||
|
"""Record a proof-of-work validation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
outcome: "success" or "failure"
|
||||||
|
"""
|
||||||
|
if _pow_counter:
|
||||||
|
_pow_counter.labels(outcome=outcome).inc()
|
||||||
|
|
||||||
|
|
||||||
|
def record_dedup(outcome: str) -> None:
|
||||||
|
"""Record a content deduplication event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
outcome: "allowed" or "blocked"
|
||||||
|
"""
|
||||||
|
if _dedup_counter:
|
||||||
|
_dedup_counter.labels(outcome=outcome).inc()
|
||||||
|
|
||||||
|
|
||||||
|
def observe_request_duration(
|
||||||
|
method: str, endpoint: str, status: int, duration: float
|
||||||
|
) -> None:
|
||||||
|
"""Record request duration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP method (GET, POST, etc.)
|
||||||
|
endpoint: Request endpoint path
|
||||||
|
status: HTTP status code
|
||||||
|
duration: Request duration in seconds
|
||||||
|
"""
|
||||||
|
if _request_duration_histogram:
|
||||||
|
_request_duration_histogram.labels(
|
||||||
|
method=method, endpoint=endpoint, status=str(status)
|
||||||
|
).observe(duration)
|
||||||
202
completions/fpaste.bash
Normal file
202
completions/fpaste.bash
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# 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 search s find update u export register cert pki completion"
|
||||||
|
local pki_commands="status issue download dl"
|
||||||
|
|
||||||
|
# Handle command-level completion
|
||||||
|
if [[ $cword -eq 1 ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local cmd="${words[1]}"
|
||||||
|
|
||||||
|
# PKI subcommand completion
|
||||||
|
if [[ "$cmd" == "pki" && $cword -eq 2 ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "$pki_commands" -- "$cur"))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Option completion based on command
|
||||||
|
case "$cmd" in
|
||||||
|
create|c|new)
|
||||||
|
case "$prev" in
|
||||||
|
-x|--expiry)
|
||||||
|
# Suggest common expiry values
|
||||||
|
COMPREPLY=($(compgen -W "60 300 600 3600 86400 604800" -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
-p|--password)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "-E --no-encrypt -b --burn -x --expiry -p --password -r --raw -q --quiet -C --clipboard --copy-url" -- "$cur"))
|
||||||
|
else
|
||||||
|
_filedir
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
get|g)
|
||||||
|
case "$prev" in
|
||||||
|
-o|--output)
|
||||||
|
_filedir
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
-p|--password)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "-o --output -c --copy -p --password -m --meta" -- "$cur"))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
delete|d|rm)
|
||||||
|
case "$prev" in
|
||||||
|
-c|--confirm)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "-a --all -c --confirm" -- "$cur"))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
info|i)
|
||||||
|
# No options
|
||||||
|
;;
|
||||||
|
list|ls)
|
||||||
|
case "$prev" in
|
||||||
|
-l|--limit|-o|--offset)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "-a --all -l --limit -o --offset --json" -- "$cur"))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
search|s|find)
|
||||||
|
case "$prev" in
|
||||||
|
-t|--type)
|
||||||
|
COMPREPLY=($(compgen -W "text/* image/* application/*" -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
--after|--before)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
-l|--limit)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "-t --type --after --before -l --limit --json" -- "$cur"))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
update|u)
|
||||||
|
case "$prev" in
|
||||||
|
-x|--expiry)
|
||||||
|
COMPREPLY=($(compgen -W "60 300 600 3600 86400 604800" -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
-p|--password)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "-E --no-encrypt -p --password --remove-password -x --expiry -q --quiet" -- "$cur"))
|
||||||
|
else
|
||||||
|
_filedir
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
export)
|
||||||
|
case "$prev" in
|
||||||
|
-o|--output|-k|--keyfile)
|
||||||
|
_filedir
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "-o --output -k --keyfile --manifest -q --quiet" -- "$cur"))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
register)
|
||||||
|
case "$prev" in
|
||||||
|
-n|--name|-o|--output)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "-n --name -o --output --configure --p12-only -f --force -q --quiet" -- "$cur"))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
cert)
|
||||||
|
case "$prev" in
|
||||||
|
-o|--output)
|
||||||
|
_filedir -d
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
-a|--algorithm)
|
||||||
|
COMPREPLY=($(compgen -W "rsa ec" -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
-c|--curve)
|
||||||
|
COMPREPLY=($(compgen -W "secp256r1 secp384r1 secp521r1" -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
-b|--bits|-d|--days|-n|--name|--password-key)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "-o --output -a --algorithm -b --bits -c --curve -d --days -n --name --password-key --configure -f --force" -- "$cur"))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
pki)
|
||||||
|
local pki_cmd="${words[2]}"
|
||||||
|
case "$pki_cmd" in
|
||||||
|
issue)
|
||||||
|
case "$prev" in
|
||||||
|
-n|--name|-o|--output)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "-n --name -o --output --configure -f --force" -- "$cur"))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
download|dl)
|
||||||
|
case "$prev" in
|
||||||
|
-o|--output)
|
||||||
|
_filedir
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "-o --output --configure" -- "$cur"))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
completion)
|
||||||
|
case "$prev" in
|
||||||
|
--shell)
|
||||||
|
COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$cur" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "--shell" -- "$cur"))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Global options
|
||||||
|
if [[ "$cur" == -* && $cword -eq 1 ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "-s --server -h --help" -- "$cur"))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
complete -F _fpaste_completions fpaste
|
||||||
148
completions/fpaste.fish
Normal file
148
completions/fpaste.fish
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Fish completion for fpaste
|
||||||
|
# Install: copy to ~/.config/fish/completions/fpaste.fish
|
||||||
|
|
||||||
|
# Disable file completions by default
|
||||||
|
complete -c fpaste -f
|
||||||
|
|
||||||
|
# Helper function to check if command is specified
|
||||||
|
function __fpaste_needs_command
|
||||||
|
set -l cmd (commandline -opc)
|
||||||
|
if test (count $cmd) -eq 1
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function __fpaste_using_command
|
||||||
|
set -l cmd (commandline -opc)
|
||||||
|
if test (count $cmd) -gt 1
|
||||||
|
if test $argv[1] = $cmd[2]
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function __fpaste_using_pki_command
|
||||||
|
set -l cmd (commandline -opc)
|
||||||
|
if test (count $cmd) -gt 2
|
||||||
|
if test $cmd[2] = 'pki' -a $argv[1] = $cmd[3]
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
# Global options
|
||||||
|
complete -c fpaste -s s -l server -d 'Server URL'
|
||||||
|
complete -c fpaste -s h -l help -d 'Show help'
|
||||||
|
|
||||||
|
# Commands
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'create' -d 'Create a new paste'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'c' -d 'Create a new paste'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'new' -d 'Create a new paste'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'get' -d 'Retrieve a paste'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'g' -d 'Retrieve a paste'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'delete' -d 'Delete paste(s)'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'd' -d 'Delete paste(s)'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'rm' -d 'Delete paste(s)'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'info' -d 'Show server info'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'i' -d 'Show server info'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'list' -d 'List your pastes'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'ls' -d 'List your pastes'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'search' -d 'Search your pastes'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 's' -d 'Search your pastes'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'find' -d 'Search your pastes'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'update' -d 'Update existing paste'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'u' -d 'Update existing paste'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'export' -d 'Export all pastes'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'register' -d 'Register and get certificate'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'cert' -d 'Generate client certificate'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'pki' -d 'PKI operations'
|
||||||
|
complete -c fpaste -n '__fpaste_needs_command' -a 'completion' -d 'Generate shell completion'
|
||||||
|
|
||||||
|
# create command options
|
||||||
|
complete -c fpaste -n '__fpaste_using_command create; or __fpaste_using_command c; or __fpaste_using_command new' -s E -l no-encrypt -d 'Disable encryption'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command create; or __fpaste_using_command c; or __fpaste_using_command new' -s b -l burn -d 'Burn after read'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command create; or __fpaste_using_command c; or __fpaste_using_command new' -s x -l expiry -d 'Expiry in seconds' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command create; or __fpaste_using_command c; or __fpaste_using_command new' -s p -l password -d 'Password protect' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command create; or __fpaste_using_command c; or __fpaste_using_command new' -s r -l raw -d 'Output raw URL'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command create; or __fpaste_using_command c; or __fpaste_using_command new' -s q -l quiet -d 'Output ID only'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command create; or __fpaste_using_command c; or __fpaste_using_command new' -s C -l clipboard -d 'Read from clipboard'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command create; or __fpaste_using_command c; or __fpaste_using_command new' -l copy-url -d 'Copy result URL to clipboard'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command create; or __fpaste_using_command c; or __fpaste_using_command new' -F
|
||||||
|
|
||||||
|
# get command options
|
||||||
|
complete -c fpaste -n '__fpaste_using_command get; or __fpaste_using_command g' -s o -l output -d 'Save to file' -r
|
||||||
|
complete -c fpaste -n '__fpaste_using_command get; or __fpaste_using_command g' -s c -l copy -d 'Copy content to clipboard'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command get; or __fpaste_using_command g' -s p -l password -d 'Password' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command get; or __fpaste_using_command g' -s m -l meta -d 'Show metadata only'
|
||||||
|
|
||||||
|
# delete command options
|
||||||
|
complete -c fpaste -n '__fpaste_using_command delete; or __fpaste_using_command d; or __fpaste_using_command rm' -s a -l all -d 'Delete all pastes'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command delete; or __fpaste_using_command d; or __fpaste_using_command rm' -s c -l confirm -d 'Confirm count' -x
|
||||||
|
|
||||||
|
# list command options
|
||||||
|
complete -c fpaste -n '__fpaste_using_command list; or __fpaste_using_command ls' -s a -l all -d 'List all pastes (admin)'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command list; or __fpaste_using_command ls' -s l -l limit -d 'Max pastes' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command list; or __fpaste_using_command ls' -s o -l offset -d 'Skip first N pastes' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command list; or __fpaste_using_command ls' -l json -d 'Output as JSON'
|
||||||
|
|
||||||
|
# search command options
|
||||||
|
complete -c fpaste -n '__fpaste_using_command search; or __fpaste_using_command s; or __fpaste_using_command find' -s t -l type -d 'Filter by MIME type' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command search; or __fpaste_using_command s; or __fpaste_using_command find' -l after -d 'Created after' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command search; or __fpaste_using_command s; or __fpaste_using_command find' -l before -d 'Created before' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command search; or __fpaste_using_command s; or __fpaste_using_command find' -s l -l limit -d 'Max results' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command search; or __fpaste_using_command s; or __fpaste_using_command find' -l json -d 'Output as JSON'
|
||||||
|
|
||||||
|
# update command options
|
||||||
|
complete -c fpaste -n '__fpaste_using_command update; or __fpaste_using_command u' -s E -l no-encrypt -d 'Disable encryption'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command update; or __fpaste_using_command u' -s p -l password -d 'Set/change password' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command update; or __fpaste_using_command u' -l remove-password -d 'Remove password'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command update; or __fpaste_using_command u' -s x -l expiry -d 'Extend expiry' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command update; or __fpaste_using_command u' -s q -l quiet -d 'Minimal output'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command update; or __fpaste_using_command u' -F
|
||||||
|
|
||||||
|
# export command options
|
||||||
|
complete -c fpaste -n '__fpaste_using_command export' -s o -l output -d 'Output directory' -r
|
||||||
|
complete -c fpaste -n '__fpaste_using_command export' -s k -l keyfile -d 'Key file' -r
|
||||||
|
complete -c fpaste -n '__fpaste_using_command export' -l manifest -d 'Write manifest.json'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command export' -s q -l quiet -d 'Minimal output'
|
||||||
|
|
||||||
|
# register command options
|
||||||
|
complete -c fpaste -n '__fpaste_using_command register' -s n -l name -d 'Common name' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command register' -s o -l output -d 'Output directory' -r
|
||||||
|
complete -c fpaste -n '__fpaste_using_command register' -l configure -d 'Update config file'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command register' -l p12-only -d 'Save only PKCS#12'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command register' -s f -l force -d 'Overwrite existing files'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command register' -s q -l quiet -d 'Minimal output'
|
||||||
|
|
||||||
|
# cert command options
|
||||||
|
complete -c fpaste -n '__fpaste_using_command cert' -s o -l output -d 'Output directory' -r
|
||||||
|
complete -c fpaste -n '__fpaste_using_command cert' -s a -l algorithm -d 'Key algorithm' -x -a 'rsa ec'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command cert' -s b -l bits -d 'RSA key size' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command cert' -s c -l curve -d 'EC curve' -x -a 'secp256r1 secp384r1 secp521r1'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command cert' -s d -l days -d 'Validity period' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command cert' -s n -l name -d 'Common name' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command cert' -l password-key -d 'Encrypt private key' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_command cert' -l configure -d 'Update config file'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command cert' -s f -l force -d 'Overwrite existing files'
|
||||||
|
|
||||||
|
# pki subcommands
|
||||||
|
complete -c fpaste -n '__fpaste_using_command pki' -a 'status' -d 'Show PKI status'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command pki' -a 'issue' -d 'Request certificate from server'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command pki' -a 'download' -d 'Download CA certificate'
|
||||||
|
complete -c fpaste -n '__fpaste_using_command pki' -a 'dl' -d 'Download CA certificate'
|
||||||
|
|
||||||
|
# pki issue options
|
||||||
|
complete -c fpaste -n '__fpaste_using_pki_command issue' -s n -l name -d 'Common name' -x
|
||||||
|
complete -c fpaste -n '__fpaste_using_pki_command issue' -s o -l output -d 'Output directory' -r
|
||||||
|
complete -c fpaste -n '__fpaste_using_pki_command issue' -l configure -d 'Update config file'
|
||||||
|
complete -c fpaste -n '__fpaste_using_pki_command issue' -s f -l force -d 'Overwrite existing files'
|
||||||
|
|
||||||
|
# pki download options
|
||||||
|
complete -c fpaste -n '__fpaste_using_pki_command download; or __fpaste_using_pki_command dl' -s o -l output -d 'Save to file' -r
|
||||||
|
complete -c fpaste -n '__fpaste_using_pki_command download; or __fpaste_using_pki_command dl' -l configure -d 'Update config file'
|
||||||
|
|
||||||
|
# completion command options
|
||||||
|
complete -c fpaste -n '__fpaste_using_command completion' -l shell -d 'Shell type' -x -a 'bash zsh fish'
|
||||||
203
completions/fpaste.zsh
Normal file
203
completions/fpaste.zsh
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
#compdef fpaste
|
||||||
|
# Zsh completion for fpaste
|
||||||
|
# Install: copy to ~/.zfunc/_fpaste and add 'fpath=(~/.zfunc $fpath)' to ~/.zshrc
|
||||||
|
|
||||||
|
_fpaste() {
|
||||||
|
local curcontext="$curcontext" state line
|
||||||
|
typeset -A opt_args
|
||||||
|
|
||||||
|
_arguments -C \
|
||||||
|
'-s[Server URL]:url:' \
|
||||||
|
'--server[Server URL]:url:' \
|
||||||
|
'-h[Show help]' \
|
||||||
|
'--help[Show help]' \
|
||||||
|
'1: :->command' \
|
||||||
|
'*:: :->args'
|
||||||
|
|
||||||
|
case $state in
|
||||||
|
command)
|
||||||
|
local commands=(
|
||||||
|
'create:Create a new paste'
|
||||||
|
'c:Create a new paste'
|
||||||
|
'new:Create a new paste'
|
||||||
|
'get:Retrieve a paste'
|
||||||
|
'g:Retrieve a paste'
|
||||||
|
'delete:Delete paste(s)'
|
||||||
|
'd:Delete paste(s)'
|
||||||
|
'rm:Delete paste(s)'
|
||||||
|
'info:Show server info'
|
||||||
|
'i:Show server info'
|
||||||
|
'list:List your pastes'
|
||||||
|
'ls:List your pastes'
|
||||||
|
'search:Search your pastes'
|
||||||
|
's:Search your pastes'
|
||||||
|
'find:Search your pastes'
|
||||||
|
'update:Update existing paste'
|
||||||
|
'u:Update existing paste'
|
||||||
|
'export:Export all pastes'
|
||||||
|
'register:Register and get certificate'
|
||||||
|
'cert:Generate client certificate'
|
||||||
|
'pki:PKI operations'
|
||||||
|
'completion:Generate shell completion'
|
||||||
|
)
|
||||||
|
_describe -t commands 'fpaste commands' commands
|
||||||
|
;;
|
||||||
|
args)
|
||||||
|
case $line[1] in
|
||||||
|
create|c|new)
|
||||||
|
_arguments \
|
||||||
|
'-E[Disable encryption]' \
|
||||||
|
'--no-encrypt[Disable encryption]' \
|
||||||
|
'-b[Burn after read]' \
|
||||||
|
'--burn[Burn after read]' \
|
||||||
|
'-x[Expiry in seconds]:seconds:' \
|
||||||
|
'--expiry[Expiry in seconds]:seconds:' \
|
||||||
|
'-p[Password protect]:password:' \
|
||||||
|
'--password[Password protect]:password:' \
|
||||||
|
'-r[Output raw URL]' \
|
||||||
|
'--raw[Output raw URL]' \
|
||||||
|
'-q[Output ID only]' \
|
||||||
|
'--quiet[Output ID only]' \
|
||||||
|
'-C[Read from clipboard]' \
|
||||||
|
'--clipboard[Read from clipboard]' \
|
||||||
|
'--copy-url[Copy result URL to clipboard]' \
|
||||||
|
'*:file:_files'
|
||||||
|
;;
|
||||||
|
get|g)
|
||||||
|
_arguments \
|
||||||
|
'-o[Save to file]:file:_files' \
|
||||||
|
'--output[Save to file]:file:_files' \
|
||||||
|
'-c[Copy content to clipboard]' \
|
||||||
|
'--copy[Copy content to clipboard]' \
|
||||||
|
'-p[Password]:password:' \
|
||||||
|
'--password[Password]:password:' \
|
||||||
|
'-m[Show metadata only]' \
|
||||||
|
'--meta[Show metadata only]' \
|
||||||
|
'1:paste ID:'
|
||||||
|
;;
|
||||||
|
delete|d|rm)
|
||||||
|
_arguments \
|
||||||
|
'-a[Delete all pastes]' \
|
||||||
|
'--all[Delete all pastes]' \
|
||||||
|
'-c[Confirm count]:count:' \
|
||||||
|
'--confirm[Confirm count]:count:' \
|
||||||
|
'*:paste ID:'
|
||||||
|
;;
|
||||||
|
info|i)
|
||||||
|
;;
|
||||||
|
list|ls)
|
||||||
|
_arguments \
|
||||||
|
'-a[List all pastes (admin)]' \
|
||||||
|
'--all[List all pastes (admin)]' \
|
||||||
|
'-l[Max pastes]:number:' \
|
||||||
|
'--limit[Max pastes]:number:' \
|
||||||
|
'-o[Skip first N pastes]:number:' \
|
||||||
|
'--offset[Skip first N pastes]:number:' \
|
||||||
|
'--json[Output as JSON]'
|
||||||
|
;;
|
||||||
|
search|s|find)
|
||||||
|
_arguments \
|
||||||
|
'-t[Filter by MIME type]:pattern:' \
|
||||||
|
'--type[Filter by MIME type]:pattern:' \
|
||||||
|
'--after[Created after]:date:' \
|
||||||
|
'--before[Created before]:date:' \
|
||||||
|
'-l[Max results]:number:' \
|
||||||
|
'--limit[Max results]:number:' \
|
||||||
|
'--json[Output as JSON]'
|
||||||
|
;;
|
||||||
|
update|u)
|
||||||
|
_arguments \
|
||||||
|
'-E[Disable encryption]' \
|
||||||
|
'--no-encrypt[Disable encryption]' \
|
||||||
|
'-p[Set/change password]:password:' \
|
||||||
|
'--password[Set/change password]:password:' \
|
||||||
|
'--remove-password[Remove password]' \
|
||||||
|
'-x[Extend expiry]:seconds:' \
|
||||||
|
'--expiry[Extend expiry]:seconds:' \
|
||||||
|
'-q[Minimal output]' \
|
||||||
|
'--quiet[Minimal output]' \
|
||||||
|
'1:paste ID:' \
|
||||||
|
'*:file:_files'
|
||||||
|
;;
|
||||||
|
export)
|
||||||
|
_arguments \
|
||||||
|
'-o[Output directory]:directory:_files -/' \
|
||||||
|
'--output[Output directory]:directory:_files -/' \
|
||||||
|
'-k[Key file]:file:_files' \
|
||||||
|
'--keyfile[Key file]:file:_files' \
|
||||||
|
'--manifest[Write manifest.json]' \
|
||||||
|
'-q[Minimal output]' \
|
||||||
|
'--quiet[Minimal output]'
|
||||||
|
;;
|
||||||
|
register)
|
||||||
|
_arguments \
|
||||||
|
'-n[Common name]:name:' \
|
||||||
|
'--name[Common name]:name:' \
|
||||||
|
'-o[Output directory]:directory:_files -/' \
|
||||||
|
'--output[Output directory]:directory:_files -/' \
|
||||||
|
'--configure[Update config file]' \
|
||||||
|
'--p12-only[Save only PKCS#12]' \
|
||||||
|
'-f[Overwrite existing files]' \
|
||||||
|
'--force[Overwrite existing files]' \
|
||||||
|
'-q[Minimal output]' \
|
||||||
|
'--quiet[Minimal output]'
|
||||||
|
;;
|
||||||
|
cert)
|
||||||
|
_arguments \
|
||||||
|
'-o[Output directory]:directory:_files -/' \
|
||||||
|
'--output[Output directory]:directory:_files -/' \
|
||||||
|
'-a[Key algorithm]:algorithm:(rsa ec)' \
|
||||||
|
'--algorithm[Key algorithm]:algorithm:(rsa ec)' \
|
||||||
|
'-b[RSA key size]:bits:' \
|
||||||
|
'--bits[RSA key size]:bits:' \
|
||||||
|
'-c[EC curve]:curve:(secp256r1 secp384r1 secp521r1)' \
|
||||||
|
'--curve[EC curve]:curve:(secp256r1 secp384r1 secp521r1)' \
|
||||||
|
'-d[Validity period]:days:' \
|
||||||
|
'--days[Validity period]:days:' \
|
||||||
|
'-n[Common name]:name:' \
|
||||||
|
'--name[Common name]:name:' \
|
||||||
|
'--password-key[Encrypt private key]:password:' \
|
||||||
|
'--configure[Update config file]' \
|
||||||
|
'-f[Overwrite existing files]' \
|
||||||
|
'--force[Overwrite existing files]'
|
||||||
|
;;
|
||||||
|
pki)
|
||||||
|
local pki_commands=(
|
||||||
|
'status:Show PKI status'
|
||||||
|
'issue:Request certificate from server'
|
||||||
|
'download:Download CA certificate'
|
||||||
|
'dl:Download CA certificate'
|
||||||
|
)
|
||||||
|
if (( CURRENT == 2 )); then
|
||||||
|
_describe -t commands 'pki commands' pki_commands
|
||||||
|
else
|
||||||
|
case $line[2] in
|
||||||
|
issue)
|
||||||
|
_arguments \
|
||||||
|
'-n[Common name]:name:' \
|
||||||
|
'--name[Common name]:name:' \
|
||||||
|
'-o[Output directory]:directory:_files -/' \
|
||||||
|
'--output[Output directory]:directory:_files -/' \
|
||||||
|
'--configure[Update config file]' \
|
||||||
|
'-f[Overwrite existing files]' \
|
||||||
|
'--force[Overwrite existing files]'
|
||||||
|
;;
|
||||||
|
download|dl)
|
||||||
|
_arguments \
|
||||||
|
'-o[Save to file]:file:_files' \
|
||||||
|
'--output[Save to file]:file:_files' \
|
||||||
|
'--configure[Update config file]'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
completion)
|
||||||
|
_arguments \
|
||||||
|
'--shell[Shell type]:shell:(bash zsh fish)'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
_fpaste "$@"
|
||||||
287
fpaste
287
fpaste
@@ -8,7 +8,9 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import ssl
|
import ssl
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -431,13 +433,76 @@ def print_paste_list(
|
|||||||
print(f"\n{summary}")
|
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
|
# Content helpers
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def read_content(file_arg: str | None) -> bytes:
|
def read_content(file_arg: str | None, from_clipboard: bool = False) -> bytes:
|
||||||
"""Read content from file or stdin."""
|
"""Read content from file, stdin, or clipboard."""
|
||||||
|
if from_clipboard:
|
||||||
|
return read_clipboard()
|
||||||
|
|
||||||
if file_arg:
|
if file_arg:
|
||||||
if file_arg == "-":
|
if file_arg == "-":
|
||||||
return sys.stdin.buffer.read()
|
return sys.stdin.buffer.read()
|
||||||
@@ -447,7 +512,7 @@ def read_content(file_arg: str | None) -> bytes:
|
|||||||
return path.read_bytes()
|
return path.read_bytes()
|
||||||
|
|
||||||
if sys.stdin.isatty():
|
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()
|
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:
|
def cmd_create(args: argparse.Namespace, config: dict[str, Any]) -> None:
|
||||||
"""Create a new paste."""
|
"""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:
|
if not content:
|
||||||
die("empty content")
|
die("empty content")
|
||||||
|
|
||||||
@@ -562,11 +628,19 @@ def cmd_create(args: argparse.Namespace, config: dict[str, Any]) -> None:
|
|||||||
base_url = config["server"].rstrip("/")
|
base_url = config["server"].rstrip("/")
|
||||||
|
|
||||||
if args.raw:
|
if args.raw:
|
||||||
print(base_url + data["raw"] + key_fragment)
|
result_url = base_url + data["raw"] + key_fragment
|
||||||
elif args.quiet:
|
elif args.quiet:
|
||||||
print(data["id"] + key_fragment)
|
result_url = data["id"] + key_fragment
|
||||||
else:
|
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
|
return
|
||||||
|
|
||||||
last_error = parse_error(body, body.decode(errors="replace"))
|
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:
|
if encryption_key:
|
||||||
body = decrypt_content(body, 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)
|
Path(args.output).write_bytes(body)
|
||||||
print(f"saved: {args.output}", file=sys.stderr)
|
print(f"saved: {args.output}", file=sys.stderr)
|
||||||
else:
|
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("-p", "--password", metavar="PASS", help="password protect")
|
||||||
p_create.add_argument("-r", "--raw", action="store_true", help="output raw URL")
|
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("-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
|
# get
|
||||||
p_get = subparsers.add_parser("get", aliases=["g"], help="retrieve paste")
|
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("id", help="paste ID or URL")
|
||||||
p_get.add_argument("-o", "--output", help="save to file")
|
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("-p", "--password", metavar="PASS", help="password for protected paste")
|
||||||
p_get.add_argument("-m", "--meta", action="store_true", help="show metadata only")
|
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)"
|
"--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
|
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
|
# Command dispatch table
|
||||||
COMMANDS: dict[str, Any] = {
|
COMMANDS: dict[str, Any] = {
|
||||||
"create": cmd_create,
|
"create": cmd_create,
|
||||||
@@ -1444,6 +1714,7 @@ COMMANDS: dict[str, Any] = {
|
|||||||
"export": cmd_export,
|
"export": cmd_export,
|
||||||
"register": cmd_register,
|
"register": cmd_register,
|
||||||
"cert": cmd_cert,
|
"cert": cmd_cert,
|
||||||
|
"completion": cmd_completion,
|
||||||
}
|
}
|
||||||
|
|
||||||
PKI_COMMANDS: dict[str, Any] = {
|
PKI_COMMANDS: dict[str, Any] = {
|
||||||
|
|||||||
312
tests/test_audit.py
Normal file
312
tests/test_audit.py
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
"""Tests for audit logging system."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from app.audit import AuditEvent, AuditOutcome, cleanup_old_audit_logs, log_event, query_audit_log
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app() -> Flask:
|
||||||
|
"""Create application configured for testing."""
|
||||||
|
app = create_app("testing")
|
||||||
|
app.config["POW_DIFFICULTY"] = 0
|
||||||
|
app.config["RATE_LIMIT_ENABLED"] = False
|
||||||
|
app.config["AUDIT_ENABLED"] = True
|
||||||
|
app.config["AUDIT_RETENTION_DAYS"] = 90
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app: Flask) -> FlaskClient:
|
||||||
|
"""Create test client."""
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_client(app: Flask) -> FlaskClient:
|
||||||
|
"""Create test client with admin authentication."""
|
||||||
|
client = app.test_client()
|
||||||
|
fingerprint = "b" * 40
|
||||||
|
client.environ_base["HTTP_X_SSL_CLIENT_SHA1"] = fingerprint
|
||||||
|
with app.app_context():
|
||||||
|
app.config["PKI_ENABLED"] = True
|
||||||
|
db = get_db()
|
||||||
|
now = int(time.time())
|
||||||
|
db.execute(
|
||||||
|
"""INSERT OR REPLACE INTO certificate_authority
|
||||||
|
(id, common_name, certificate_pem, private_key_encrypted,
|
||||||
|
key_salt, created_at, expires_at, key_algorithm)
|
||||||
|
VALUES ('default', 'Test CA', 'CERT', X'00', X'00', ?, ?, 'rsa')""",
|
||||||
|
(now, now + 86400 * 365),
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"""INSERT OR REPLACE INTO issued_certificates
|
||||||
|
(serial, ca_id, common_name, fingerprint_sha1, certificate_pem,
|
||||||
|
created_at, expires_at, status, is_admin)
|
||||||
|
VALUES (?, 'default', 'admin', ?, 'CERT', ?, ?, 'valid', 1)""",
|
||||||
|
("admin001", fingerprint, now, now + 86400),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuditLogging:
|
||||||
|
"""Test audit logging functions."""
|
||||||
|
|
||||||
|
def test_log_event_creates_record(self, app: Flask) -> None:
|
||||||
|
"""log_event should create audit log entry."""
|
||||||
|
with app.app_context():
|
||||||
|
log_event(
|
||||||
|
AuditEvent.PASTE_CREATE,
|
||||||
|
AuditOutcome.SUCCESS,
|
||||||
|
paste_id="abc123",
|
||||||
|
client_id="a" * 40,
|
||||||
|
client_ip="192.168.1.1",
|
||||||
|
details={"size": 1024},
|
||||||
|
)
|
||||||
|
|
||||||
|
entries, total = query_audit_log(paste_id="abc123")
|
||||||
|
|
||||||
|
assert total == 1
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0]["event_type"] == "paste_create"
|
||||||
|
assert entries[0]["outcome"] == "success"
|
||||||
|
assert entries[0]["paste_id"] == "abc123"
|
||||||
|
assert entries[0]["client_id"] == "a" * 40
|
||||||
|
assert entries[0]["client_ip"] == "192.168.1.1"
|
||||||
|
assert entries[0]["details"]["size"] == 1024
|
||||||
|
|
||||||
|
def test_log_event_with_string_types(self, app: Flask) -> None:
|
||||||
|
"""log_event should accept string types as well as enums."""
|
||||||
|
with app.app_context():
|
||||||
|
log_event(
|
||||||
|
"custom_event",
|
||||||
|
"custom_outcome",
|
||||||
|
paste_id="xyz789",
|
||||||
|
)
|
||||||
|
|
||||||
|
entries, total = query_audit_log(event_type="custom_event")
|
||||||
|
|
||||||
|
assert total == 1
|
||||||
|
assert entries[0]["event_type"] == "custom_event"
|
||||||
|
assert entries[0]["outcome"] == "custom_outcome"
|
||||||
|
|
||||||
|
def test_log_event_disabled(self, app: Flask) -> None:
|
||||||
|
"""log_event should skip logging when disabled."""
|
||||||
|
app.config["AUDIT_ENABLED"] = False
|
||||||
|
with app.app_context():
|
||||||
|
log_event(
|
||||||
|
AuditEvent.PASTE_CREATE,
|
||||||
|
AuditOutcome.SUCCESS,
|
||||||
|
paste_id="disabled123",
|
||||||
|
)
|
||||||
|
|
||||||
|
_entries, total = query_audit_log(paste_id="disabled123")
|
||||||
|
assert total == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuditQuery:
|
||||||
|
"""Test audit log query functionality."""
|
||||||
|
|
||||||
|
def test_query_by_event_type(self, app: Flask) -> None:
|
||||||
|
"""Query should filter by event type."""
|
||||||
|
with app.app_context():
|
||||||
|
log_event(AuditEvent.PASTE_CREATE, AuditOutcome.SUCCESS, paste_id="evt_p1")
|
||||||
|
log_event(AuditEvent.PASTE_DELETE, AuditOutcome.SUCCESS, paste_id="evt_p2")
|
||||||
|
log_event(AuditEvent.PASTE_CREATE, AuditOutcome.SUCCESS, paste_id="evt_p3")
|
||||||
|
|
||||||
|
entries, _total = query_audit_log(event_type="paste_create")
|
||||||
|
|
||||||
|
# Verify entries matching our test are present
|
||||||
|
our_entries = [e for e in entries if e.get("paste_id", "").startswith("evt_")]
|
||||||
|
assert len(our_entries) == 2
|
||||||
|
assert all(e["event_type"] == "paste_create" for e in entries)
|
||||||
|
|
||||||
|
def test_query_by_client_id(self, app: Flask) -> None:
|
||||||
|
"""Query should filter by client ID."""
|
||||||
|
with app.app_context():
|
||||||
|
# Use unique client IDs for this test
|
||||||
|
client_a = "1" * 40
|
||||||
|
client_b = "2" * 40
|
||||||
|
log_event(
|
||||||
|
AuditEvent.PASTE_CREATE, AuditOutcome.SUCCESS, client_id=client_a, paste_id="cid_p1"
|
||||||
|
)
|
||||||
|
log_event(
|
||||||
|
AuditEvent.PASTE_CREATE, AuditOutcome.SUCCESS, client_id=client_b, paste_id="cid_p2"
|
||||||
|
)
|
||||||
|
|
||||||
|
entries, total = query_audit_log(client_id=client_a)
|
||||||
|
|
||||||
|
assert total >= 1
|
||||||
|
assert all(e["client_id"] == client_a for e in entries)
|
||||||
|
|
||||||
|
def test_query_by_outcome(self, app: Flask) -> None:
|
||||||
|
"""Query should filter by outcome."""
|
||||||
|
with app.app_context():
|
||||||
|
log_event(AuditEvent.RATE_LIMIT, AuditOutcome.BLOCKED, paste_id="out_test")
|
||||||
|
log_event(AuditEvent.PASTE_CREATE, AuditOutcome.SUCCESS)
|
||||||
|
log_event(AuditEvent.POW_FAILURE, AuditOutcome.FAILURE)
|
||||||
|
|
||||||
|
entries, total = query_audit_log(outcome="blocked")
|
||||||
|
|
||||||
|
assert total >= 1
|
||||||
|
assert all(e["outcome"] == "blocked" for e in entries)
|
||||||
|
|
||||||
|
def test_query_by_timestamp_range(self, app: Flask) -> None:
|
||||||
|
"""Query should filter by timestamp range."""
|
||||||
|
with app.app_context():
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
log_event(AuditEvent.PASTE_CREATE, AuditOutcome.SUCCESS, paste_id="recent")
|
||||||
|
|
||||||
|
entries, total = query_audit_log(since=now - 60, until=now + 60)
|
||||||
|
|
||||||
|
assert total >= 1
|
||||||
|
assert any(e["paste_id"] == "recent" for e in entries)
|
||||||
|
|
||||||
|
def test_query_pagination(self, app: Flask) -> None:
|
||||||
|
"""Query should support pagination."""
|
||||||
|
with app.app_context():
|
||||||
|
for i in range(5):
|
||||||
|
log_event(AuditEvent.PASTE_CREATE, AuditOutcome.SUCCESS, paste_id=f"p{i}")
|
||||||
|
|
||||||
|
entries1, total = query_audit_log(limit=2, offset=0)
|
||||||
|
entries2, _ = query_audit_log(limit=2, offset=2)
|
||||||
|
|
||||||
|
assert total >= 5
|
||||||
|
assert len(entries1) == 2
|
||||||
|
assert len(entries2) == 2
|
||||||
|
assert entries1[0]["paste_id"] != entries2[0]["paste_id"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuditCleanup:
|
||||||
|
"""Test audit log cleanup functionality."""
|
||||||
|
|
||||||
|
def test_cleanup_old_logs(self, app: Flask) -> None:
|
||||||
|
"""Cleanup should delete old entries."""
|
||||||
|
with app.app_context():
|
||||||
|
db = get_db()
|
||||||
|
old_ts = int(time.time()) - (100 * 24 * 60 * 60) # 100 days ago
|
||||||
|
|
||||||
|
# Insert old entry directly
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO audit_log
|
||||||
|
(timestamp, event_type, outcome)
|
||||||
|
VALUES (?, 'old_event', 'success')""",
|
||||||
|
(old_ts,),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Insert recent entry
|
||||||
|
log_event(AuditEvent.PASTE_CREATE, AuditOutcome.SUCCESS, paste_id="recent")
|
||||||
|
|
||||||
|
# Cleanup with 90 day retention
|
||||||
|
deleted = cleanup_old_audit_logs(retention_days=90)
|
||||||
|
|
||||||
|
assert deleted == 1
|
||||||
|
|
||||||
|
entries, _total = query_audit_log()
|
||||||
|
assert all(e["event_type"] != "old_event" for e in entries)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuditEndpoint:
|
||||||
|
"""Test GET /audit endpoint."""
|
||||||
|
|
||||||
|
def test_audit_requires_auth(self, client: FlaskClient) -> None:
|
||||||
|
"""Audit endpoint should require authentication."""
|
||||||
|
response = client.get("/audit")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_audit_requires_admin(self, app: Flask) -> None:
|
||||||
|
"""Audit endpoint should require admin access."""
|
||||||
|
client = app.test_client()
|
||||||
|
client.environ_base["HTTP_X_SSL_CLIENT_SHA1"] = "c" * 40 # Non-admin
|
||||||
|
|
||||||
|
response = client.get("/audit")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_audit_admin_access(self, admin_client: FlaskClient, app: Flask) -> None:
|
||||||
|
"""Admin should be able to query audit log."""
|
||||||
|
with app.app_context():
|
||||||
|
log_event(AuditEvent.PASTE_CREATE, AuditOutcome.SUCCESS, paste_id="test123")
|
||||||
|
|
||||||
|
response = admin_client.get("/audit")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert "entries" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert "count" in data
|
||||||
|
|
||||||
|
def test_audit_query_params(self, admin_client: FlaskClient, app: Flask) -> None:
|
||||||
|
"""Audit endpoint should accept query parameters."""
|
||||||
|
with app.app_context():
|
||||||
|
log_event(AuditEvent.PASTE_CREATE, AuditOutcome.SUCCESS, paste_id="filtered")
|
||||||
|
log_event(AuditEvent.PASTE_DELETE, AuditOutcome.SUCCESS, paste_id="other")
|
||||||
|
|
||||||
|
response = admin_client.get("/audit?event_type=paste_create")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert all(e["event_type"] == "paste_create" for e in data["entries"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuditIntegration:
|
||||||
|
"""Test audit logging integration with paste operations."""
|
||||||
|
|
||||||
|
def test_paste_create_logs_event(self, client: FlaskClient, app: Flask) -> None:
|
||||||
|
"""Paste creation should log audit event."""
|
||||||
|
response = client.post("/", data=b"test content")
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
paste_id = response.get_json()["id"]
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
entries, _ = query_audit_log(paste_id=paste_id, event_type="paste_create")
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0]["outcome"] == "success"
|
||||||
|
|
||||||
|
def test_paste_access_logs_event(self, client: FlaskClient, app: Flask) -> None:
|
||||||
|
"""Paste access should log audit event."""
|
||||||
|
# Create paste
|
||||||
|
create_response = client.post("/", data=b"access test")
|
||||||
|
paste_id = create_response.get_json()["id"]
|
||||||
|
|
||||||
|
# Access paste
|
||||||
|
client.get(f"/{paste_id}/raw")
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
entries, _ = query_audit_log(paste_id=paste_id, event_type="paste_access")
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0]["outcome"] == "success"
|
||||||
|
|
||||||
|
def test_rate_limit_logs_event(self, app: Flask) -> None:
|
||||||
|
"""Rate limit block should log audit event."""
|
||||||
|
app.config["RATE_LIMIT_ENABLED"] = True
|
||||||
|
app.config["RATE_LIMIT_WINDOW"] = 60
|
||||||
|
app.config["RATE_LIMIT_MAX"] = 1
|
||||||
|
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
# First request should succeed
|
||||||
|
client.post("/", data=b"first")
|
||||||
|
|
||||||
|
# Second request should be rate limited
|
||||||
|
response = client.post("/", data=b"second")
|
||||||
|
assert response.status_code == 429
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
entries, _ = query_audit_log(event_type="rate_limit")
|
||||||
|
assert len(entries) >= 1
|
||||||
|
assert entries[0]["outcome"] == "blocked"
|
||||||
179
tests/test_metrics.py
Normal file
179
tests/test_metrics.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""Tests for Prometheus metrics module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from app.metrics import (
|
||||||
|
record_dedup,
|
||||||
|
record_paste_accessed,
|
||||||
|
record_paste_created,
|
||||||
|
record_paste_deleted,
|
||||||
|
record_pow,
|
||||||
|
record_rate_limit,
|
||||||
|
setup_custom_metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app() -> Flask:
|
||||||
|
"""Create application configured for testing."""
|
||||||
|
app = create_app("testing")
|
||||||
|
app.config["POW_DIFFICULTY"] = 0
|
||||||
|
app.config["RATE_LIMIT_ENABLED"] = False
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app: Flask) -> FlaskClient:
|
||||||
|
"""Create test client."""
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetricsFunctions:
|
||||||
|
"""Test that metrics functions execute without error."""
|
||||||
|
|
||||||
|
def test_record_paste_created_no_error(self) -> None:
|
||||||
|
"""record_paste_created should not raise when counters not initialized."""
|
||||||
|
# In testing mode, counters are None, should gracefully no-op
|
||||||
|
record_paste_created("anonymous", "success")
|
||||||
|
record_paste_created("authenticated", "success")
|
||||||
|
record_paste_created("anonymous", "failure")
|
||||||
|
|
||||||
|
def test_record_paste_accessed_no_error(self) -> None:
|
||||||
|
"""record_paste_accessed should not raise when counters not initialized."""
|
||||||
|
record_paste_accessed("anonymous", False)
|
||||||
|
record_paste_accessed("authenticated", True)
|
||||||
|
|
||||||
|
def test_record_paste_deleted_no_error(self) -> None:
|
||||||
|
"""record_paste_deleted should not raise when counters not initialized."""
|
||||||
|
record_paste_deleted("authenticated", "success")
|
||||||
|
record_paste_deleted("authenticated", "failure")
|
||||||
|
|
||||||
|
def test_record_rate_limit_no_error(self) -> None:
|
||||||
|
"""record_rate_limit should not raise when counters not initialized."""
|
||||||
|
record_rate_limit("allowed")
|
||||||
|
record_rate_limit("blocked")
|
||||||
|
|
||||||
|
def test_record_pow_no_error(self) -> None:
|
||||||
|
"""record_pow should not raise when counters not initialized."""
|
||||||
|
record_pow("success")
|
||||||
|
record_pow("failure")
|
||||||
|
|
||||||
|
def test_record_dedup_no_error(self) -> None:
|
||||||
|
"""record_dedup should not raise when counters not initialized."""
|
||||||
|
record_dedup("allowed")
|
||||||
|
record_dedup("blocked")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetricsSetup:
|
||||||
|
"""Test metrics initialization."""
|
||||||
|
|
||||||
|
def test_setup_skipped_in_testing(self, app: Flask) -> None:
|
||||||
|
"""setup_custom_metrics should skip initialization in testing mode."""
|
||||||
|
with app.app_context():
|
||||||
|
# Should not raise even though prometheus_client is available
|
||||||
|
setup_custom_metrics(app)
|
||||||
|
|
||||||
|
def test_setup_handles_missing_prometheus(self, app: Flask) -> None:
|
||||||
|
"""setup_custom_metrics should handle missing prometheus_client gracefully."""
|
||||||
|
app.config["TESTING"] = False
|
||||||
|
|
||||||
|
with app.app_context(), patch.dict("sys.modules", {"prometheus_client": None}):
|
||||||
|
# Should not raise, just log warning
|
||||||
|
setup_custom_metrics(app)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetricsIntegration:
|
||||||
|
"""Test metrics are recorded during API operations."""
|
||||||
|
|
||||||
|
def test_paste_create_records_metric(self, client: FlaskClient) -> None:
|
||||||
|
"""Paste creation should call record_paste_created."""
|
||||||
|
with patch("app.api.routes.record_paste_created") as mock_record:
|
||||||
|
response = client.post("/", data=b"test content")
|
||||||
|
assert response.status_code == 201
|
||||||
|
mock_record.assert_called_once_with("anonymous", "success")
|
||||||
|
|
||||||
|
def test_paste_access_records_metric(self, client: FlaskClient) -> None:
|
||||||
|
"""Paste access should call record_paste_accessed."""
|
||||||
|
# Create paste first
|
||||||
|
create_response = client.post("/", data=b"access test")
|
||||||
|
paste_id = create_response.get_json()["id"]
|
||||||
|
|
||||||
|
with patch("app.api.routes.record_paste_accessed") as mock_record:
|
||||||
|
response = client.get(f"/{paste_id}/raw")
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_record.assert_called_once_with("anonymous", False)
|
||||||
|
|
||||||
|
def test_burn_after_read_records_metric(self, client: FlaskClient) -> None:
|
||||||
|
"""Burn-after-read access should record burn=True."""
|
||||||
|
# Create burn-after-read paste
|
||||||
|
create_response = client.post(
|
||||||
|
"/",
|
||||||
|
data=b"burn test",
|
||||||
|
headers={"X-Burn-After-Read": "true"},
|
||||||
|
)
|
||||||
|
paste_id = create_response.get_json()["id"]
|
||||||
|
|
||||||
|
with patch("app.api.routes.record_paste_accessed") as mock_record:
|
||||||
|
response = client.get(f"/{paste_id}/raw")
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_record.assert_called_once_with("anonymous", True)
|
||||||
|
|
||||||
|
def test_dedup_allowed_records_metric(self, client: FlaskClient) -> None:
|
||||||
|
"""Successful paste creation should call record_dedup with 'allowed'."""
|
||||||
|
with patch("app.api.routes.record_dedup") as mock_record:
|
||||||
|
response = client.post("/", data=b"unique content for dedup test")
|
||||||
|
assert response.status_code == 201
|
||||||
|
mock_record.assert_called_once_with("allowed")
|
||||||
|
|
||||||
|
def test_pow_success_records_metric(self, app: Flask) -> None:
|
||||||
|
"""Successful PoW verification should call record_pow with 'success'."""
|
||||||
|
app.config["POW_DIFFICULTY"] = 8
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
# Get a challenge
|
||||||
|
challenge_resp = client.get("/challenge")
|
||||||
|
challenge = challenge_resp.get_json()
|
||||||
|
|
||||||
|
# Solve it (brute force for testing)
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
nonce = challenge["nonce"]
|
||||||
|
difficulty = challenge["difficulty"]
|
||||||
|
token = challenge["token"]
|
||||||
|
|
||||||
|
solution = 0
|
||||||
|
while True:
|
||||||
|
work = f"{nonce}:{solution}".encode()
|
||||||
|
hash_bytes = hashlib.sha256(work).digest()
|
||||||
|
zero_bits = 0
|
||||||
|
for byte in hash_bytes:
|
||||||
|
if byte == 0:
|
||||||
|
zero_bits += 8
|
||||||
|
else:
|
||||||
|
zero_bits += 8 - byte.bit_length()
|
||||||
|
break
|
||||||
|
if zero_bits >= difficulty:
|
||||||
|
break
|
||||||
|
solution += 1
|
||||||
|
|
||||||
|
with patch("app.api.routes.record_pow") as mock_record:
|
||||||
|
response = client.post(
|
||||||
|
"/",
|
||||||
|
data=b"pow test content",
|
||||||
|
headers={
|
||||||
|
"X-PoW-Token": token,
|
||||||
|
"X-PoW-Solution": str(solution),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
mock_record.assert_called_with("success")
|
||||||
Reference in New Issue
Block a user