Skip to content

Commit 0006b5d

Browse files
authored
Merge pull request #87 from smswithoutborders/staging
Staging
2 parents 0ec0548 + b3c9dc3 commit 0006b5d

16 files changed

Lines changed: 706 additions & 52 deletions

Dockerfile

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
1-
FROM python:3.13.4-slim
1+
FROM python:3.13.5-slim
22

33
WORKDIR /publisher
44

5-
RUN apt-get update && apt-get install -y --no-install-recommends \
5+
RUN --mount=type=cache,sharing=locked,target=/var/cache/apt \
6+
--mount=type=cache,sharing=locked,target=/var/lib/apt \
7+
apt-get update && apt-get install -y --no-install-recommends \
68
build-essential \
79
python3-dev \
810
default-libmysqlclient-dev \
911
supervisor \
1012
curl \
1113
git \
14+
vim \
1215
pkg-config && \
1316
apt-get clean && \
1417
rm -rf /var/lib/apt/lists/*
1518

1619
COPY requirements.txt .
17-
RUN pip install --disable-pip-version-check --quiet --no-cache-dir -r requirements.txt
20+
21+
RUN --mount=type=cache,sharing=locked,target=/root/.cache/pip \
22+
pip install --disable-pip-version-check --quiet --no-cache-dir -r requirements.txt
1823

1924
COPY . .
2025

api_schemas.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,21 @@ class PublicationsResponse(BaseModel):
4040
class PlatformManifest(BaseModel):
4141
name: str
4242
shortcode: str
43-
protocol: str
43+
protocol_type: str
4444
service_type: str
4545
icon_svg: Optional[str] = None
4646
icon_png: Optional[str] = None
4747
support_url_scheme: Optional[bool] = None
48+
49+
50+
class OAuthClientMetadata(BaseModel):
51+
client_id: str
52+
dpop_bound_access_tokens: bool
53+
application_type: str
54+
redirect_uris: list[str]
55+
grant_types: list[str]
56+
response_types: list[str]
57+
scope: str
58+
token_endpoint_auth_method: str
59+
client_name: str
60+
client_uri: str

api_v1.py

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@
55
"""
66

77
import datetime
8+
import json
9+
from pathlib import Path as PathLib
810
from typing import Optional, List
9-
from fastapi import APIRouter, HTTPException, Query, Path
11+
from fastapi import APIRouter, HTTPException, Query, Path, Request
12+
from fastapi.responses import HTMLResponse
1013
from api_schemas import (
1114
PublicationsRead,
1215
PublicationsResponse,
1316
Pagination,
1417
PlatformManifest,
18+
OAuthClientMetadata,
1519
)
1620
from publications import fetch_publication
1721
from platforms.adapter_manager import AdapterManager
@@ -24,12 +28,13 @@
2428
ALLOWED_PLATFORM_MANIFEST_KEYS = [
2529
"name",
2630
"shortcode",
27-
"protocol",
31+
"protocol_type",
2832
"service_type",
2933
"icon_svg",
3034
"icon_png",
3135
"support_url_scheme",
3236
]
37+
ALLOWED_PLATFORMS_WITH_CLIENT_METADATA = ["bluesky"]
3338

3439

3540
@router.get("/metrics/publications", response_model=PublicationsResponse)
@@ -135,3 +140,95 @@ def get_platform_data(
135140
if key in ALLOWED_PLATFORM_MANIFEST_KEYS
136141
}
137142
return adapter_copy
143+
144+
145+
@router.get("/platforms/{platform_name}/oauth/client-metadata.json")
146+
def get_platform_oauth_client_metadata(
147+
platform_name: str = Path(
148+
..., description="Platform name", pattern=r"^[a-zA-Z0-9_-]+$"
149+
)
150+
) -> OAuthClientMetadata:
151+
"""Retrieve the OAuth client metadata for a platform adapter."""
152+
AdapterManager._populate_registry()
153+
adapter = next(
154+
(
155+
manifest
156+
for manifest in AdapterManager._registry.values()
157+
if manifest["name"].lower() == platform_name.lower()
158+
),
159+
None,
160+
)
161+
if not adapter:
162+
raise HTTPException(status_code=404, detail="Platform not found")
163+
164+
if not platform_name.lower() in ALLOWED_PLATFORMS_WITH_CLIENT_METADATA:
165+
raise HTTPException(
166+
status_code=404,
167+
detail="OAuth client metadata not available for this platform",
168+
)
169+
170+
adapter_credentials = PathLib(adapter.get("path")) / "credentials.json"
171+
172+
if not adapter_credentials.exists():
173+
raise HTTPException(
174+
status_code=404,
175+
detail="OAuth client metadata file not found for this platform",
176+
)
177+
178+
try:
179+
with open(adapter_credentials, "r", encoding="utf-8") as file:
180+
creds = file.read()
181+
client_metadata = OAuthClientMetadata(**json.loads(creds))
182+
return client_metadata
183+
except FileNotFoundError as exc:
184+
logger.error("OAuth client metadata file not found")
185+
raise HTTPException(
186+
status_code=404, detail="OAuth client metadata file not found"
187+
) from exc
188+
189+
190+
@router.get("/platforms/{platform_name}/oauth/callback")
191+
async def oauth_callback(
192+
request: Request,
193+
platform_name: str = Path(
194+
..., description="Platform name", pattern=r"^[a-zA-Z0-9_-]+$"
195+
),
196+
) -> HTMLResponse:
197+
"""
198+
Handle the OAuth callback from the platform.
199+
"""
200+
AdapterManager._populate_registry()
201+
adapter = next(
202+
(
203+
manifest
204+
for manifest in AdapterManager._registry.values()
205+
if manifest["name"].lower() == platform_name.lower()
206+
),
207+
None,
208+
)
209+
if not adapter:
210+
raise HTTPException(status_code=404, detail="Platform not found")
211+
212+
if not platform_name.lower() in ALLOWED_PLATFORMS_WITH_CLIENT_METADATA:
213+
raise HTTPException(
214+
status_code=404,
215+
detail="OAuth client metadata not available for this platform",
216+
)
217+
218+
table_rows = ""
219+
for key, value in request.query_params.items():
220+
table_rows += f"<tr><td>{key}</td><td>{value}</td></tr>"
221+
222+
html_content = f"""
223+
<html>
224+
<head><title>{platform_name.capitalize()} OAuth Callback Params</title></head>
225+
<body>
226+
<h2>{platform_name.capitalize()}'s Callback Params</h2>
227+
<table border="1">
228+
<tr><th>Parameter</th><th>Value</th></tr>
229+
{table_rows}
230+
</table>
231+
</body>
232+
</html>
233+
"""
234+
return HTMLResponse(content=html_content)

content_parser.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,85 @@ def extract_content_v1(service_type: str, content: bytes) -> tuple:
258258
return None, e
259259

260260

261+
def extract_content_v2(service_type: str, content: bytes) -> tuple:
262+
"""
263+
Extracts components from the packed content for v2 format based on the specified service_type.
264+
265+
Args:
266+
service_type (str): The type of the platform (email, text, message).
267+
content (bytes): The packed binary content to extract.
268+
269+
Returns:
270+
tuple: A tuple containing:
271+
- parts (tuple): A tuple with the parsed components based on the service_type.
272+
- error (str): An error message if extraction fails, otherwise None.
273+
"""
274+
parsers = [
275+
FormatSpec(key="length_from", fmt="<B", decoding=None),
276+
FormatSpec(key="length_to", fmt="<H", decoding=None),
277+
FormatSpec(key="length_cc", fmt="<H", decoding=None),
278+
FormatSpec(key="length_bcc", fmt="<H", decoding=None),
279+
FormatSpec(key="length_subject", fmt="<B", decoding=None),
280+
FormatSpec(key="length_body", fmt="<H", decoding=None),
281+
FormatSpec(key="length_access_token", fmt="<H", decoding=None),
282+
FormatSpec(key="length_refresh_token", fmt="<H", decoding=None),
283+
FormatSpec(key="from", fmt=lambda d: d["length_from"], decoding="utf-8"),
284+
FormatSpec(key="to", fmt=lambda d: d["length_to"], decoding="utf-8"),
285+
FormatSpec(key="cc", fmt=lambda d: d["length_cc"], decoding="utf-8"),
286+
FormatSpec(key="bcc", fmt=lambda d: d["length_bcc"], decoding="utf-8"),
287+
FormatSpec(key="subject", fmt=lambda d: d["length_subject"], decoding="utf-8"),
288+
FormatSpec(key="body", fmt=lambda d: d["length_body"], decoding="utf-8"),
289+
FormatSpec(
290+
key="access_token", fmt=lambda d: d["length_access_token"], decoding="utf-8"
291+
),
292+
FormatSpec(
293+
key="refresh_token",
294+
fmt=lambda d: d["length_refresh_token"],
295+
decoding="utf-8",
296+
),
297+
]
298+
299+
try:
300+
result = parse_payload(content, parsers)
301+
302+
if service_type == "email":
303+
return (
304+
result["from"],
305+
result["to"],
306+
result["cc"],
307+
result["bcc"],
308+
result["subject"],
309+
result["body"],
310+
result.get("access_token"),
311+
result.get("refresh_token"),
312+
), None
313+
314+
if service_type == "text":
315+
return (
316+
result["from"],
317+
result["body"],
318+
result.get("access_token"),
319+
result.get("refresh_token"),
320+
), None
321+
322+
if service_type == "message":
323+
return (
324+
result["from"],
325+
result["to"],
326+
result["body"],
327+
), None
328+
329+
if service_type == "test":
330+
return (result["from"],), None
331+
332+
return (
333+
None,
334+
"Invalid service_type. Must be 'email', 'text', 'message', or 'test'.",
335+
)
336+
except Exception as e:
337+
return None, e
338+
339+
261340
def is_v0_payload(payload):
262341
"""Determines if the given payload follows v0 format."""
263342
try:

docs/grpc.md

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ start the OAuth2 flow.
8686
> | ------------- | --------- | ------------ | -------- | -------- |
8787
> | Gmail | g | Email | OAuth2 | Optional |
8888
> | Twitter | t | Text | OAuth2 | Required |
89+
> | Telegram | T | Message | PNBA | N/A |
8990
> | Reliability | r | Test | event | N/A |
91+
> | Bluesky | b | Text | OAuth2 | Required |
9092
9193
---
9294

@@ -110,12 +112,8 @@ Optional fields:
110112
| state | string | An opaque value used to maintain state between the request and the callback. |
111113
| code_verifier | string | A cryptographic random string used in the PKCE flow. |
112114
| autogenerate_code_verifier | bool | If true, a code verifier will be auto-generated if not provided. |
113-
114-
Optional fields:
115-
116-
| Field | Type | Description |
117-
| ------------ | ------ | -------------------------------------------- |
118-
| redirect_url | string | The redirect URL for the OAuth2 application. |
115+
| redirect_url | string | The redirect URL for the OAuth2 application. |
116+
| request_identifier | string | A request identifier for tracking the request |
119117

120118
---
121119

@@ -167,7 +165,8 @@ localhost:6000 publisher.v1.Publisher/GetOAuth2AuthorizationUrl <payload.json
167165
"platform": "gmail",
168166
"state": "",
169167
"code_verifier": "",
170-
"autogenerate_code_verifier": true
168+
"autogenerate_code_verifier": true,
169+
"request_identifier": ""
171170
}
172171
```
173172

@@ -269,11 +268,12 @@ tokens in the vault.
269268
270269
Optional fields:
271270
272-
| Field | Type | Description |
273-
| --------------- | ------ | --------------------------------------------------------------------------- |
274-
| code_verifier | string | A cryptographic random string used in the PKCE flow. |
275-
| redirect_url | string | The redirect URL for the OAuth2 application. |
276-
| store_on_device | bool | Indicates if the token should be stored on the device instead of the cloud. |
271+
| Field | Type | Description |
272+
| ------------------ | ------ | --------------------------------------------------------------------------- |
273+
| code_verifier | string | A cryptographic random string used in the PKCE flow. |
274+
| redirect_url | string | The redirect URL for the OAuth2 application. |
275+
| store_on_device | bool | Indicates if the token should be stored on the device instead of the cloud. |
276+
| request_identifier | string | A request identifier for tracking the request |
277277
278278
---
279279
@@ -322,7 +322,8 @@ localhost:6000 publisher.v1.Publisher/ExchangeOAuth2CodeAndStore <payload.json
322322
"platform": "gmail",
323323
"authorization_code": "auth_code",
324324
"code_verifier": "abcdef",
325-
"store_on_device": false
325+
"store_on_device": false,
326+
"request_identifier": ""
326327
}
327328
```
328329
@@ -441,6 +442,12 @@ This method sends a one-time passcode (OTP) to the user's phone number for authe
441442
| phone_number | string | The phone number to which the OTP is sent. |
442443
| platform | string | The platform identifier for which the authorization code is generated. (e.g., "telegram"). |
443444
445+
Optional fields:
446+
447+
| Field | Type | Description |
448+
| ------------------ | ------ | --------------------------------------------- |
449+
| request_identifier | string | A request identifier for tracking the request |
450+
444451
---
445452
446453
##### Response
@@ -482,7 +489,8 @@ localhost:6000 publisher.v1.Publisher/GetPNBACode <payload.json
482489
```json
483490
{
484491
"phone_number": "+1234567890",
485-
"platform": "telegram"
492+
"platform": "telegram",
493+
"request_identifier": ""
486494
}
487495
```
488496
@@ -520,9 +528,10 @@ This method exchanges the one-time passcode (OTP) for an access token and stores
520528
521529
Optional fields:
522530
523-
| Field | Type | Description |
524-
| -------- | ------ | --------------------------------------- |
525-
| password | string | The password for two-step verification. |
531+
| Field | Type | Description |
532+
| ------------------ | ------ | --------------------------------------------- |
533+
| password | string | The password for two-step verification. |
534+
| request_identifier | string | A request identifier for tracking the request |
526535
527536
---
528537
@@ -569,7 +578,8 @@ localhost:6000 publisher.v1.Publisher/ExchangePNBACodeAndStore <payload.json
569578
"long_lived_token": "long_lived_token",
570579
"password": "",
571580
"phone_number": "+1234567890",
572-
"platform": "telegram"
581+
"platform": "telegram",
582+
"request_identifier": ""
573583
}
574584
```
575585

0 commit comments

Comments
 (0)