pwpush-mcp 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pwpush_mcp/__init__.py +5 -0
- pwpush_mcp/__main__.py +97 -0
- pwpush_mcp/audit.py +110 -0
- pwpush_mcp/client.py +448 -0
- pwpush_mcp/config.py +143 -0
- pwpush_mcp/durations.py +123 -0
- pwpush_mcp/server.py +383 -0
- pwpush_mcp-0.2.0.dist-info/METADATA +223 -0
- pwpush_mcp-0.2.0.dist-info/RECORD +12 -0
- pwpush_mcp-0.2.0.dist-info/WHEEL +4 -0
- pwpush_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- pwpush_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
pwpush_mcp/__init__.py
ADDED
pwpush_mcp/__main__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Entry point: ``python -m pwpush_mcp`` or the ``pwpush-mcp`` console script.
|
|
2
|
+
|
|
3
|
+
Default transport is stdio (Claude Desktop / Claude Code / Docker MCP Gateway).
|
|
4
|
+
Pass ``--listen PORT`` to expose the server over Streamable-HTTP/SSE behind a
|
|
5
|
+
network gateway.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .server import build_server
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("pwpush_mcp")
|
|
20
|
+
|
|
21
|
+
_SSL_WARNING = (
|
|
22
|
+
"SECURITY WARNING: PWPUSH_VERIFY_SSL=false — TLS certificate verification is "
|
|
23
|
+
"DISABLED. All HTTPS connections are vulnerable to MITM attacks. Set "
|
|
24
|
+
"PWPUSH_VERIFY_SSL=true (or PWPUSH_CA_BUNDLE) for production use."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _run_stdio() -> None:
|
|
29
|
+
from mcp.server.stdio import stdio_server
|
|
30
|
+
|
|
31
|
+
server = build_server()
|
|
32
|
+
if not server._cfg.verify_ssl:
|
|
33
|
+
log.warning(_SSL_WARNING)
|
|
34
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
35
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _run_http(host: str, port: int, log_level: str) -> None:
|
|
39
|
+
"""Run as an SSE / Streamable-HTTP server."""
|
|
40
|
+
import uvicorn
|
|
41
|
+
from mcp.server.sse import SseServerTransport
|
|
42
|
+
from starlette.applications import Starlette
|
|
43
|
+
from starlette.responses import Response
|
|
44
|
+
from starlette.routing import Mount, Route
|
|
45
|
+
|
|
46
|
+
server = build_server()
|
|
47
|
+
if not server._cfg.verify_ssl:
|
|
48
|
+
log.warning(_SSL_WARNING)
|
|
49
|
+
sse = SseServerTransport("/messages/")
|
|
50
|
+
|
|
51
|
+
async def handle_sse(request: Any) -> Response: # starlette Request
|
|
52
|
+
async with sse.connect_sse(request.scope, request.receive, request._send) as streams:
|
|
53
|
+
await server.run(streams[0], streams[1], server.create_initialization_options())
|
|
54
|
+
return Response()
|
|
55
|
+
|
|
56
|
+
app = Starlette(
|
|
57
|
+
routes=[
|
|
58
|
+
Route("/sse", endpoint=handle_sse),
|
|
59
|
+
Mount("/messages/", app=sse.handle_post_message),
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
config = uvicorn.Config(app, host=host, port=port, log_level=log_level.lower())
|
|
63
|
+
await uvicorn.Server(config).serve()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main(argv: list[str] | None = None) -> int:
|
|
67
|
+
parser = argparse.ArgumentParser(prog="pwpush-mcp", description="Password Pusher MCP server.")
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--listen",
|
|
70
|
+
type=int,
|
|
71
|
+
metavar="PORT",
|
|
72
|
+
help="Run as an HTTP/SSE server on this port. Default: stdio mode.",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument("--host", default="0.0.0.0", help="Bind host for --listen mode.")
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"]
|
|
77
|
+
)
|
|
78
|
+
args = parser.parse_args(argv)
|
|
79
|
+
|
|
80
|
+
logging.basicConfig(
|
|
81
|
+
level=args.log_level,
|
|
82
|
+
stream=sys.stderr,
|
|
83
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Fail fast on a missing base URL / malformed config before opening transport.
|
|
87
|
+
Config.from_env()
|
|
88
|
+
|
|
89
|
+
if args.listen:
|
|
90
|
+
asyncio.run(_run_http(args.host, args.listen, args.log_level))
|
|
91
|
+
else:
|
|
92
|
+
asyncio.run(_run_stdio())
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
raise SystemExit(main())
|
pwpush_mcp/audit.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Structured audit logging for write operations.
|
|
2
|
+
|
|
3
|
+
Every invocation of a WRITE tool (``create_push``, ``expire_push``) emits one
|
|
4
|
+
JSON line on the ``pwpush_mcp.audit`` logger. The default destination is
|
|
5
|
+
stderr, which makes it trivial to ship to Loki / CloudWatch / journald via the
|
|
6
|
+
container runtime.
|
|
7
|
+
|
|
8
|
+
The payload contains:
|
|
9
|
+
- ts: ISO-8601 timestamp (UTC, second precision)
|
|
10
|
+
- tool: MCP tool name
|
|
11
|
+
- args: redacted call arguments (secret-bearing keys stripped)
|
|
12
|
+
- target: best-effort identifier (url_token / name) for grep-ability
|
|
13
|
+
- status: "ok" | "error"
|
|
14
|
+
- error: scrubbed exception text (only when status=error)
|
|
15
|
+
|
|
16
|
+
The secret ``payload`` / ``passphrase`` / file contents are NEVER logged.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
__all__ = ["configure", "log_call", "scrub"]
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("pwpush_mcp.audit")
|
|
31
|
+
|
|
32
|
+
# Argument keys whose values must never appear in the audit log.
|
|
33
|
+
_REDACT: frozenset[str] = frozenset({"payload", "passphrase", "token", "api_token", "file_paths"})
|
|
34
|
+
|
|
35
|
+
# Free-text secret patterns applied by :func:`scrub` to any string about to be
|
|
36
|
+
# logged or surfaced to the operator. Each pattern keeps the *label* (group 1)
|
|
37
|
+
# so the scrubbed string stays diagnosable (e.g. ``Authorization: Bearer ***``).
|
|
38
|
+
_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
39
|
+
re.compile(r"(authorization\s*:\s*bearer\s+)\S+", re.IGNORECASE),
|
|
40
|
+
re.compile(r"(X-User-Token\s*[:=]\s*)[^&\s\"']+", re.IGNORECASE),
|
|
41
|
+
re.compile(r"(\bapi[_-]?token\s*=\s*)[^&\s\"']+", re.IGNORECASE),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def scrub(text: str) -> str:
|
|
46
|
+
"""Return *text* with known secret-bearing substrings masked.
|
|
47
|
+
|
|
48
|
+
Idempotent and safe to call on already-scrubbed strings.
|
|
49
|
+
"""
|
|
50
|
+
for pat in _SECRET_PATTERNS:
|
|
51
|
+
text = pat.sub(r"\1***", text)
|
|
52
|
+
return text
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _redact(value: Any) -> Any:
|
|
56
|
+
if isinstance(value, dict):
|
|
57
|
+
return {k: ("***" if k in _REDACT else _redact(v)) for k, v in value.items()}
|
|
58
|
+
if isinstance(value, list):
|
|
59
|
+
return [_redact(v) for v in value]
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _target(args: dict[str, Any]) -> str | None:
|
|
64
|
+
token = args.get("url_token")
|
|
65
|
+
if token:
|
|
66
|
+
return str(token)
|
|
67
|
+
name = args.get("name")
|
|
68
|
+
if name:
|
|
69
|
+
return f"name:{name}"
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def log_call(
|
|
74
|
+
tool_name: str,
|
|
75
|
+
arguments: dict[str, Any],
|
|
76
|
+
*,
|
|
77
|
+
status: str = "ok",
|
|
78
|
+
error: str | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Emit one audit JSON line. Called from the call_tool handler."""
|
|
81
|
+
record: dict[str, Any] = {
|
|
82
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
83
|
+
"tool": tool_name,
|
|
84
|
+
"args": _redact(arguments),
|
|
85
|
+
"target": _target(arguments),
|
|
86
|
+
"status": status,
|
|
87
|
+
}
|
|
88
|
+
if error:
|
|
89
|
+
record["error"] = scrub(error)
|
|
90
|
+
log.info(json.dumps(record, default=str))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def configure(enabled: bool = True) -> None:
|
|
94
|
+
"""Idempotently configure the audit logger.
|
|
95
|
+
|
|
96
|
+
When enabled, emit one JSON line per record on stderr. ``enabled=False``
|
|
97
|
+
silences it via a NullHandler.
|
|
98
|
+
"""
|
|
99
|
+
log.handlers.clear()
|
|
100
|
+
if not enabled:
|
|
101
|
+
log.addHandler(logging.NullHandler())
|
|
102
|
+
log.propagate = False
|
|
103
|
+
return
|
|
104
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
105
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
106
|
+
log.addHandler(handler)
|
|
107
|
+
log.setLevel(logging.INFO)
|
|
108
|
+
# propagate=True so pytest's caplog can intercept; the root logger is
|
|
109
|
+
# unconfigured by default, so this does not duplicate output in production.
|
|
110
|
+
log.propagate = True
|
pwpush_mcp/client.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""Thin async wrapper around the Password Pusher API (v1 and v2).
|
|
2
|
+
|
|
3
|
+
Two API generations exist in the wild:
|
|
4
|
+
|
|
5
|
+
- **v2** (pwpush.com, eu.pwpush.com, recent self-hosted): JSON under
|
|
6
|
+
``/api/v2/pushes``, a ``push`` wrapper, ``expire_after_duration`` as an enum
|
|
7
|
+
index 0..17, and ``Authorization: Bearer`` auth.
|
|
8
|
+
- **v1** (older self-hosted instances): the classic ``/p.json`` endpoints, a
|
|
9
|
+
``password`` wrapper, ``expire_after_days`` (whole days), and
|
|
10
|
+
``X-User-Token`` / ``X-User-Email`` auth.
|
|
11
|
+
|
|
12
|
+
The client auto-detects the generation (overridable via ``PWPUSH_API_VERSION``)
|
|
13
|
+
and presents a single normalized interface to the tools.
|
|
14
|
+
|
|
15
|
+
Design invariants (both versions):
|
|
16
|
+
- The secret ``payload``/``files`` are never logged and are stripped from any
|
|
17
|
+
object returned to callers.
|
|
18
|
+
- No "retrieve" operation is exposed: retrieving a push consumes a view.
|
|
19
|
+
- Authentication is sent when configured; an operation only fails up-front for
|
|
20
|
+
a missing token when the endpoint is inherently account-scoped (list, audit).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import mimetypes
|
|
27
|
+
import random
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
from . import __version__
|
|
34
|
+
from .config import Config
|
|
35
|
+
from .durations import resolve_days, resolve_duration
|
|
36
|
+
|
|
37
|
+
# API fields that must never be returned to the model.
|
|
38
|
+
_SENSITIVE_FIELDS = ("payload", "files")
|
|
39
|
+
|
|
40
|
+
SUPPORTED_KINDS = ("text", "url", "qr", "file")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PwpushError(Exception):
|
|
44
|
+
"""Raised for any API, authentication, or network failure."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FeatureDisabledError(PwpushError):
|
|
48
|
+
"""Raised when an instance does not enable a requested push type."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _public(obj: Any) -> Any:
|
|
52
|
+
"""Return a copy of an API object with sensitive fields removed."""
|
|
53
|
+
if isinstance(obj, list):
|
|
54
|
+
return [_public(item) for item in obj]
|
|
55
|
+
if isinstance(obj, dict):
|
|
56
|
+
return {k: v for k, v in obj.items() if k not in _SENSITIVE_FIELDS}
|
|
57
|
+
return obj
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _safe_detail(resp: httpx.Response) -> str:
|
|
61
|
+
"""Extract a human-readable error message without echoing secrets."""
|
|
62
|
+
try:
|
|
63
|
+
data = resp.json()
|
|
64
|
+
except ValueError:
|
|
65
|
+
text = resp.text.strip()
|
|
66
|
+
return text[:200] if text else f"HTTP {resp.status_code}"
|
|
67
|
+
if isinstance(data, dict):
|
|
68
|
+
for key in ("error", "message", "errors"):
|
|
69
|
+
if key in data:
|
|
70
|
+
return str(data[key])
|
|
71
|
+
return f"HTTP {resp.status_code}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _form_value(value: Any) -> str:
|
|
75
|
+
if isinstance(value, bool):
|
|
76
|
+
return "true" if value else "false"
|
|
77
|
+
return str(value)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _open_files(file_paths: list[str], field: str) -> tuple[list, list]:
|
|
81
|
+
"""Open local files for multipart upload. Caller must close the handles."""
|
|
82
|
+
files: list[tuple[str, Any]] = []
|
|
83
|
+
handles: list[Any] = []
|
|
84
|
+
try:
|
|
85
|
+
for raw in file_paths:
|
|
86
|
+
path = Path(raw).expanduser()
|
|
87
|
+
if not path.is_file():
|
|
88
|
+
raise PwpushError(f"file not found: {raw}")
|
|
89
|
+
handle = path.open("rb")
|
|
90
|
+
handles.append(handle)
|
|
91
|
+
ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
92
|
+
files.append((field, (path.name, handle, ctype)))
|
|
93
|
+
except Exception:
|
|
94
|
+
for handle in handles:
|
|
95
|
+
handle.close()
|
|
96
|
+
raise
|
|
97
|
+
return files, handles
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Status codes worth retrying with backoff: rate limiting and transient
|
|
101
|
+
# upstream/server errors. 4xx other than 429 are caller errors — never retried.
|
|
102
|
+
_RETRYABLE_STATUS: frozenset[int] = frozenset({429, 500, 502, 503, 504})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PwpushClient:
|
|
106
|
+
def __init__(self, config: Config, *, timeout: float | None = None) -> None:
|
|
107
|
+
self._config = config
|
|
108
|
+
self._timeout = timeout if timeout is not None else config.timeout
|
|
109
|
+
self._verify = config.verify
|
|
110
|
+
self._max_retries = config.max_retries
|
|
111
|
+
self._version: str | None = (
|
|
112
|
+
config.api_version if config.api_version in ("v1", "v2") else None
|
|
113
|
+
)
|
|
114
|
+
# Lazily created on first use so it is bound to the running event loop.
|
|
115
|
+
self._semaphore: asyncio.Semaphore | None = None
|
|
116
|
+
|
|
117
|
+
def _limiter(self) -> asyncio.Semaphore | None:
|
|
118
|
+
"""Return the concurrency semaphore, creating it on first use.
|
|
119
|
+
|
|
120
|
+
``max_concurrent == 0`` means unlimited, so no semaphore is used.
|
|
121
|
+
"""
|
|
122
|
+
if self._config.max_concurrent <= 0:
|
|
123
|
+
return None
|
|
124
|
+
if self._semaphore is None:
|
|
125
|
+
self._semaphore = asyncio.Semaphore(self._config.max_concurrent)
|
|
126
|
+
return self._semaphore
|
|
127
|
+
|
|
128
|
+
def _new_client(self) -> httpx.AsyncClient:
|
|
129
|
+
"""Build an AsyncClient with transport-level connection retries.
|
|
130
|
+
|
|
131
|
+
``httpx`` retries only connection establishment errors here; HTTP-level
|
|
132
|
+
retries (429 / 5xx) are handled explicitly in :meth:`_send` so we can
|
|
133
|
+
honour ``Retry-After`` and apply jittered backoff.
|
|
134
|
+
"""
|
|
135
|
+
transport = httpx.AsyncHTTPTransport(retries=self._max_retries)
|
|
136
|
+
return httpx.AsyncClient(timeout=self._timeout, verify=self._verify, transport=transport)
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _backoff_seconds(resp: httpx.Response, attempt: int) -> float:
|
|
140
|
+
"""Backoff delay: honour ``Retry-After`` on 429, else jittered expo."""
|
|
141
|
+
if resp.status_code == 429:
|
|
142
|
+
raw = resp.headers.get("Retry-After")
|
|
143
|
+
if raw:
|
|
144
|
+
try:
|
|
145
|
+
return min(float(raw), 30.0)
|
|
146
|
+
except ValueError:
|
|
147
|
+
pass
|
|
148
|
+
return min(2.0**attempt + random.random(), 30.0)
|
|
149
|
+
|
|
150
|
+
# -- Version detection --------------------------------------------------
|
|
151
|
+
|
|
152
|
+
async def _detect_version(self) -> str:
|
|
153
|
+
if self._version:
|
|
154
|
+
return self._version
|
|
155
|
+
url = f"{self._config.base_url}/api/v2/version.json"
|
|
156
|
+
try:
|
|
157
|
+
async with self._new_client() as client:
|
|
158
|
+
resp = await client.get(url, headers={"Accept": "application/json"})
|
|
159
|
+
except httpx.RequestError as exc:
|
|
160
|
+
raise PwpushError(f"network error contacting {self._config.base_url}: {exc}") from exc
|
|
161
|
+
# v2 instances serve this route (200, no auth); v1 instances 404 it.
|
|
162
|
+
self._version = "v2" if resp.status_code != 404 else "v1"
|
|
163
|
+
return self._version
|
|
164
|
+
|
|
165
|
+
# -- Low-level transport ------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def _headers(self, version: str, *, require_auth: bool) -> dict[str, str]:
|
|
168
|
+
headers = {
|
|
169
|
+
"Accept": "application/json",
|
|
170
|
+
"User-Agent": f"pwpush-mcp/{__version__}",
|
|
171
|
+
}
|
|
172
|
+
token = self._config.api_token
|
|
173
|
+
if version == "v2":
|
|
174
|
+
if token:
|
|
175
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
176
|
+
elif require_auth:
|
|
177
|
+
raise PwpushError(self._auth_hint())
|
|
178
|
+
else: # v1
|
|
179
|
+
if token:
|
|
180
|
+
headers["X-User-Token"] = token
|
|
181
|
+
if self._config.api_email:
|
|
182
|
+
headers["X-User-Email"] = self._config.api_email
|
|
183
|
+
elif require_auth:
|
|
184
|
+
raise PwpushError(self._auth_hint())
|
|
185
|
+
return headers
|
|
186
|
+
|
|
187
|
+
def _auth_hint(self) -> str:
|
|
188
|
+
return (
|
|
189
|
+
"This operation requires authentication, but PWPUSH_API_TOKEN is not "
|
|
190
|
+
"set. Generate a token at "
|
|
191
|
+
f"{self._config.base_url}/api_tokens and set the env var "
|
|
192
|
+
"(v1 instances also need PWPUSH_API_EMAIL)."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
async def _send(
|
|
196
|
+
self,
|
|
197
|
+
method: str,
|
|
198
|
+
path: str,
|
|
199
|
+
version: str,
|
|
200
|
+
*,
|
|
201
|
+
require_auth: bool,
|
|
202
|
+
json: dict[str, Any] | None = None,
|
|
203
|
+
params: dict[str, Any] | None = None,
|
|
204
|
+
data: dict[str, Any] | None = None,
|
|
205
|
+
files: list[tuple[str, Any]] | None = None,
|
|
206
|
+
) -> Any:
|
|
207
|
+
url = f"{self._config.base_url}{path}"
|
|
208
|
+
headers = self._headers(version, require_auth=require_auth)
|
|
209
|
+
limiter = self._limiter()
|
|
210
|
+
|
|
211
|
+
# Application-level retry loop for 429 / 5xx. Multipart uploads carry
|
|
212
|
+
# open file handles positioned at EOF after the first send, so they are
|
|
213
|
+
# not retried (file pushes are rare and one-shot by nature).
|
|
214
|
+
attempts = 1 if files else self._max_retries + 1
|
|
215
|
+
resp: httpx.Response | None = None
|
|
216
|
+
for attempt in range(attempts):
|
|
217
|
+
try:
|
|
218
|
+
if limiter is not None:
|
|
219
|
+
async with limiter, self._new_client() as client:
|
|
220
|
+
resp = await client.request(
|
|
221
|
+
method,
|
|
222
|
+
url,
|
|
223
|
+
headers=headers,
|
|
224
|
+
json=json,
|
|
225
|
+
params=params,
|
|
226
|
+
data=data,
|
|
227
|
+
files=files,
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
async with self._new_client() as client:
|
|
231
|
+
resp = await client.request(
|
|
232
|
+
method,
|
|
233
|
+
url,
|
|
234
|
+
headers=headers,
|
|
235
|
+
json=json,
|
|
236
|
+
params=params,
|
|
237
|
+
data=data,
|
|
238
|
+
files=files,
|
|
239
|
+
)
|
|
240
|
+
except httpx.RequestError as exc:
|
|
241
|
+
raise PwpushError(
|
|
242
|
+
f"network error contacting {self._config.base_url}: {exc}"
|
|
243
|
+
) from exc
|
|
244
|
+
|
|
245
|
+
if resp.status_code in _RETRYABLE_STATUS and attempt < attempts - 1:
|
|
246
|
+
await asyncio.sleep(self._backoff_seconds(resp, attempt))
|
|
247
|
+
continue
|
|
248
|
+
break
|
|
249
|
+
|
|
250
|
+
assert resp is not None # loop always assigns or raises
|
|
251
|
+
|
|
252
|
+
if resp.status_code == 429:
|
|
253
|
+
retry = resp.headers.get("Retry-After", "unknown")
|
|
254
|
+
raise PwpushError(f"rate limited (429); retry after {retry}s")
|
|
255
|
+
if resp.status_code == 401:
|
|
256
|
+
raise PwpushError(
|
|
257
|
+
"unauthorized (401): this instance/operation requires valid "
|
|
258
|
+
"credentials (PWPUSH_API_TOKEN, plus PWPUSH_API_EMAIL on v1)"
|
|
259
|
+
)
|
|
260
|
+
if resp.status_code == 403:
|
|
261
|
+
raise PwpushError("forbidden (403): the credentials lack permission for this action")
|
|
262
|
+
if resp.status_code == 404:
|
|
263
|
+
raise PwpushError("not found (404): unknown url_token, or the push has expired")
|
|
264
|
+
if resp.status_code >= 400:
|
|
265
|
+
raise PwpushError(f"API error {resp.status_code}: {_safe_detail(resp)}")
|
|
266
|
+
|
|
267
|
+
if not resp.content:
|
|
268
|
+
return {}
|
|
269
|
+
try:
|
|
270
|
+
return resp.json()
|
|
271
|
+
except ValueError as exc:
|
|
272
|
+
raise PwpushError("API returned a non-JSON response") from exc
|
|
273
|
+
|
|
274
|
+
# -- Operations ---------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
async def create_push(
|
|
277
|
+
self,
|
|
278
|
+
*,
|
|
279
|
+
payload: str | None,
|
|
280
|
+
kind: str,
|
|
281
|
+
duration: str,
|
|
282
|
+
expire_after_views: int,
|
|
283
|
+
passphrase: str | None,
|
|
284
|
+
name: str | None,
|
|
285
|
+
note: str | None,
|
|
286
|
+
deletable_by_viewer: bool,
|
|
287
|
+
retrieval_step: bool,
|
|
288
|
+
file_paths: list[str] | None = None,
|
|
289
|
+
) -> dict[str, Any]:
|
|
290
|
+
version = await self._detect_version()
|
|
291
|
+
if version == "v2":
|
|
292
|
+
return await self._create_v2(
|
|
293
|
+
payload=payload,
|
|
294
|
+
kind=kind,
|
|
295
|
+
duration=duration,
|
|
296
|
+
expire_after_views=expire_after_views,
|
|
297
|
+
passphrase=passphrase,
|
|
298
|
+
name=name,
|
|
299
|
+
note=note,
|
|
300
|
+
deletable_by_viewer=deletable_by_viewer,
|
|
301
|
+
retrieval_step=retrieval_step,
|
|
302
|
+
file_paths=file_paths,
|
|
303
|
+
)
|
|
304
|
+
return await self._create_v1(
|
|
305
|
+
payload=payload,
|
|
306
|
+
kind=kind,
|
|
307
|
+
duration=duration,
|
|
308
|
+
expire_after_views=expire_after_views,
|
|
309
|
+
passphrase=passphrase,
|
|
310
|
+
deletable_by_viewer=deletable_by_viewer,
|
|
311
|
+
retrieval_step=retrieval_step,
|
|
312
|
+
file_paths=file_paths,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
async def _create_v2(self, **k: Any) -> dict[str, Any]:
|
|
316
|
+
push: dict[str, Any] = {
|
|
317
|
+
"expire_after_duration": resolve_duration(k["duration"]),
|
|
318
|
+
"expire_after_views": k["expire_after_views"],
|
|
319
|
+
"deletable_by_viewer": k["deletable_by_viewer"],
|
|
320
|
+
"retrieval_step": k["retrieval_step"],
|
|
321
|
+
}
|
|
322
|
+
if k["payload"]:
|
|
323
|
+
push["payload"] = k["payload"]
|
|
324
|
+
if k["passphrase"]:
|
|
325
|
+
push["passphrase"] = k["passphrase"]
|
|
326
|
+
if k["name"]:
|
|
327
|
+
push["name"] = k["name"]
|
|
328
|
+
if k["note"]:
|
|
329
|
+
push["note"] = k["note"]
|
|
330
|
+
|
|
331
|
+
if k["file_paths"]:
|
|
332
|
+
form = {f"push[{key}]": _form_value(val) for key, val in push.items()}
|
|
333
|
+
form["push[kind]"] = "file"
|
|
334
|
+
files, handles = _open_files(k["file_paths"], "push[files][]")
|
|
335
|
+
try:
|
|
336
|
+
data = await self._send(
|
|
337
|
+
"POST", "/api/v2/pushes.json", "v2", require_auth=False, data=form, files=files
|
|
338
|
+
)
|
|
339
|
+
finally:
|
|
340
|
+
for handle in handles:
|
|
341
|
+
handle.close()
|
|
342
|
+
else:
|
|
343
|
+
push["kind"] = k["kind"]
|
|
344
|
+
data = await self._send(
|
|
345
|
+
"POST", "/api/v2/pushes.json", "v2", require_auth=False, json={"push": push}
|
|
346
|
+
)
|
|
347
|
+
return _public(data)
|
|
348
|
+
|
|
349
|
+
async def _create_v1(self, **k: Any) -> dict[str, Any]:
|
|
350
|
+
days = resolve_days(k["duration"])
|
|
351
|
+
common = {
|
|
352
|
+
"expire_after_views": k["expire_after_views"],
|
|
353
|
+
"expire_after_days": days,
|
|
354
|
+
"deletable_by_viewer": k["deletable_by_viewer"],
|
|
355
|
+
"retrieval_step": k["retrieval_step"],
|
|
356
|
+
}
|
|
357
|
+
if k["passphrase"]:
|
|
358
|
+
common["passphrase"] = k["passphrase"]
|
|
359
|
+
|
|
360
|
+
if k["file_paths"]:
|
|
361
|
+
form = {f"file[{key}]": _form_value(val) for key, val in common.items()}
|
|
362
|
+
if k["payload"]:
|
|
363
|
+
form["file[payload]"] = k["payload"]
|
|
364
|
+
files, handles = _open_files(k["file_paths"], "file[files][]")
|
|
365
|
+
try:
|
|
366
|
+
data = await self._send_v1_typed(
|
|
367
|
+
"/f.json", "file pushes", require_auth=False, data=form, files=files
|
|
368
|
+
)
|
|
369
|
+
finally:
|
|
370
|
+
for handle in handles:
|
|
371
|
+
handle.close()
|
|
372
|
+
elif k["kind"] == "url":
|
|
373
|
+
body = {"url": {"payload": k["payload"], **common}}
|
|
374
|
+
data = await self._send_v1_typed("/r.json", "URL pushes", require_auth=False, json=body)
|
|
375
|
+
else: # text (qr is not a distinct v1 endpoint; treated as text)
|
|
376
|
+
body = {"password": {"payload": k["payload"], **common}}
|
|
377
|
+
data = await self._send("POST", "/p.json", "v1", require_auth=False, json=body)
|
|
378
|
+
return _public(data)
|
|
379
|
+
|
|
380
|
+
async def _send_v1_typed(self, path: str, feature: str, **kw: Any) -> Any:
|
|
381
|
+
"""POST to a v1 typed-push endpoint, mapping 404 to a feature hint."""
|
|
382
|
+
try:
|
|
383
|
+
return await self._send("POST", path, "v1", **kw)
|
|
384
|
+
except PwpushError as exc:
|
|
385
|
+
if "404" in str(exc):
|
|
386
|
+
raise FeatureDisabledError(
|
|
387
|
+
f"{feature} are not enabled on this instance ({path} returned 404)"
|
|
388
|
+
) from exc
|
|
389
|
+
raise
|
|
390
|
+
|
|
391
|
+
async def preview_push(self, url_token: str) -> dict[str, Any]:
|
|
392
|
+
version = await self._detect_version()
|
|
393
|
+
if version == "v2":
|
|
394
|
+
return await self._send(
|
|
395
|
+
"GET", f"/api/v2/pushes/{url_token}/preview.json", "v2", require_auth=False
|
|
396
|
+
)
|
|
397
|
+
return await self._send("GET", f"/p/{url_token}/preview.json", "v1", require_auth=False)
|
|
398
|
+
|
|
399
|
+
async def expire_push(self, url_token: str) -> dict[str, Any]:
|
|
400
|
+
version = await self._detect_version()
|
|
401
|
+
if version == "v2":
|
|
402
|
+
data = await self._send(
|
|
403
|
+
"DELETE", f"/api/v2/pushes/{url_token}.json", "v2", require_auth=False
|
|
404
|
+
)
|
|
405
|
+
else:
|
|
406
|
+
data = await self._send("DELETE", f"/p/{url_token}.json", "v1", require_auth=False)
|
|
407
|
+
return _public(data)
|
|
408
|
+
|
|
409
|
+
async def push_audit(self, url_token: str, *, page: int = 1) -> Any:
|
|
410
|
+
version = await self._detect_version()
|
|
411
|
+
if version == "v2":
|
|
412
|
+
return await self._send(
|
|
413
|
+
"GET",
|
|
414
|
+
f"/api/v2/pushes/{url_token}/audit.json",
|
|
415
|
+
"v2",
|
|
416
|
+
require_auth=True,
|
|
417
|
+
params={"page": page},
|
|
418
|
+
)
|
|
419
|
+
return await self._send("GET", f"/p/{url_token}/audit.json", "v1", require_auth=True)
|
|
420
|
+
|
|
421
|
+
async def list_pushes(self, state: str, *, page: int = 1) -> Any:
|
|
422
|
+
if state not in ("active", "expired"):
|
|
423
|
+
raise PwpushError("state must be 'active' or 'expired'")
|
|
424
|
+
version = await self._detect_version()
|
|
425
|
+
if version == "v2":
|
|
426
|
+
data = await self._send(
|
|
427
|
+
"GET",
|
|
428
|
+
f"/api/v2/pushes/{state}.json",
|
|
429
|
+
"v2",
|
|
430
|
+
require_auth=True,
|
|
431
|
+
params={"page": page},
|
|
432
|
+
)
|
|
433
|
+
else:
|
|
434
|
+
data = await self._send("GET", f"/p/{state}.json", "v1", require_auth=True)
|
|
435
|
+
return _public(data)
|
|
436
|
+
|
|
437
|
+
async def version(self) -> dict[str, Any]:
|
|
438
|
+
detected = await self._detect_version()
|
|
439
|
+
if detected == "v2":
|
|
440
|
+
data = await self._send("GET", "/api/v2/version.json", "v2", require_auth=False)
|
|
441
|
+
if isinstance(data, dict):
|
|
442
|
+
data.setdefault("detected_api_version", "v2")
|
|
443
|
+
return data
|
|
444
|
+
# v1 instances expose no version endpoint.
|
|
445
|
+
return {
|
|
446
|
+
"detected_api_version": "v1",
|
|
447
|
+
"note": "legacy instance; no /version endpoint is exposed",
|
|
448
|
+
}
|