diff --git a/.env.example b/.env.example index 154c927..c1c4b78 100644 --- a/.env.example +++ b/.env.example @@ -90,3 +90,22 @@ POSTGRES_PASSWORD=papyrus POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5432 POSTGRES_DB=papyrus + +# Optional: fine-grained GitHub token with Issues write access to PapyrusReader/client. +# Keep this secret on the server. Empty disables public support submissions. +# Attachment uploads also require the token owner to have repository write access. +SUPPORT_GITHUB_TOKEN= + +# Support routing. Token also needs organization Projects write access. +SUPPORT_GITHUB_REPOSITORY=PapyrusReader/client +SUPPORT_GITHUB_ASSIGNEE=Eoic +SUPPORT_GITHUB_PROJECT_OWNER=PapyrusReader +SUPPORT_GITHUB_PROJECT_NUMBER=1 +SUPPORT_GITHUB_BUG_LABEL=bug +SUPPORT_GITHUB_FEATURE_LABEL=enhancement +SUPPORT_GITHUB_BUG_TYPE=Bug +SUPPORT_GITHUB_FEATURE_TYPE=Feature +SUPPORT_GITHUB_STATUS_FIELD=Status +SUPPORT_GITHUB_STATUS=Backlog +SUPPORT_GITHUB_KIND_FIELD=Kind +SUPPORT_GITHUB_KIND=Task diff --git a/README.md b/README.md index 385d29d..94d48fe 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ endpoints, jobs and rules. Provider adapters, job lifecycle and release tokens live in [the acquisition service](papyrus/services/acquisition/). The monitor imports selected media and handles retry/cancellation. [OPDS relay setup](docs/opds-relay.md) describes catalog transport and origin restrictions. +See [in-app support setup](docs/support.md) for GitHub issue submission configuration. ## Verification diff --git a/deploy/production.env.example b/deploy/production.env.example index 4cdc9d7..4d98a73 100644 --- a/deploy/production.env.example +++ b/deploy/production.env.example @@ -41,3 +41,21 @@ SMTP_FROM_EMAIL= SMTP_FROM_NAME=Papyrus ACQUISITION_ENABLED=false OPDS_RELAY_ENABLED=true + +# Optional server-held token with Issues write permission for PapyrusReader/client. +# Attachment uploads also require the token owner to have repository write access. +SUPPORT_GITHUB_TOKEN= + +# Support routing. Token also needs organization Projects write access. +SUPPORT_GITHUB_REPOSITORY=PapyrusReader/client +SUPPORT_GITHUB_ASSIGNEE=Eoic +SUPPORT_GITHUB_PROJECT_OWNER=PapyrusReader +SUPPORT_GITHUB_PROJECT_NUMBER=1 +SUPPORT_GITHUB_BUG_LABEL=bug +SUPPORT_GITHUB_FEATURE_LABEL=enhancement +SUPPORT_GITHUB_BUG_TYPE=Bug +SUPPORT_GITHUB_FEATURE_TYPE=Feature +SUPPORT_GITHUB_STATUS_FIELD=Status +SUPPORT_GITHUB_STATUS=Backlog +SUPPORT_GITHUB_KIND_FIELD=Kind +SUPPORT_GITHUB_KIND=Task diff --git a/docs/support.md b/docs/support.md new file mode 100644 index 0000000..339d832 --- /dev/null +++ b/docs/support.md @@ -0,0 +1,59 @@ +# In-app support + +`POST /v1/support/issues` accepts bug reports and feature requests from guests and +signed-in readers. It creates a public issue in the configured repository (default +`PapyrusReader/client`). The request +is multipart: `payload` contains the JSON support fields and optional repeated +`attachments` parts contain images or videos. + +Set `SUPPORT_GITHUB_TOKEN` in the server environment (`production.env` for Compose) +to a fine-grained PAT restricted to that repository with **Issues: read and write**. +Also grant organization **Projects: read and write** for the Papyrus project. +The token owner must also have repository write access for media uploads. GitHub +App installation tokens cannot upload attachments. Keep the token out of client +builds. Without it, the endpoint returns 503. + +Routing is controlled by the `SUPPORT_GITHUB_*` environment variables shown in +`.env.example` and `deploy/production.env.example`: repository, assignee, project +owner/number, labels, issue types, and project field names/values. Set overrides in +the server's ignored `.env` or deployment `production.env`. These routing values +are ordinary configuration. Only `SUPPORT_GITHUB_TOKEN` is a secret. None of these +settings can be overridden by a support request. + +By default, bug reports receive the `bug` label and native `Bug` issue type. Feature requests +receive `enhancement` and `Feature`. Both are assigned to `Eoic` and added to the +Papyrus project (#1) with Status `Backlog` and Kind `Task`. Priority, milestone, +and requirement links are left for triage. Project access and field options are +checked before uploads or issue creation. The server sends the confirmed issue +response before applying project updates in a background task. Slow project updates +cannot delay that response. If updates fail, the server logs the issue number for +manual reconciliation. Background routing is not persisted or retried, so an abrupt +server shutdown can leave project metadata incomplete. + +Reading a public project and its `viewerCanUpdate` value only confirms visibility +and the token owner's role. It does not prove the token has Projects write access. +A `FORBIDDEN` project mutation is logged with the issue number and a permission +hint. Grant organization Projects read/write to the configured token, then add +any already-created reports to the project without resubmitting them. + +The form accepts up to five PNG, JPEG, GIF, WebP, MP4, MOV, or WebM files, each up to +10 MiB. The server checks sizes and media signatures before contacting GitHub. +The multipart request is bounded to 50 MiB plus 64 KiB of form overhead, including +chunked requests. Media is uploaded through the same native GitHub attachment +endpoint used by [GitHub CLI](https://github.com/cli/cli/blob/v2.102.0/internal/attachments/client.go), +then embedded in the issue. Original filenames are replaced with generic names. +Nothing is stored in the reader's library or exposed through library media routes. + +Submissions are limited to three per hour and ten per day per client IP using the +existing process-local limiter. Configure trusted proxy forwarding so requests +retain their client IP. Multiple API workers need a shared limiter or equivalent +edge limits before increasing deployment scale. + +Each GitHub request has a 30-second socket timeout. The client allows four minutes +for submission and never retries automatically. A failed submission preserves the +form and selected files. An attachment failure prevents issue creation, but files +already accepted by GitHub may remain public even if a later step fails. GitHub +does not provide an attachment deletion API. An issue-creation timeout may mean +GitHub created the issue without the client receiving confirmation. + +Tests mock GitHub and never create public issues or upload real attachments. diff --git a/papyrus/api/routes/__init__.py b/papyrus/api/routes/__init__.py index b1b24af..30398bc 100644 --- a/papyrus/api/routes/__init__.py +++ b/papyrus/api/routes/__init__.py @@ -21,6 +21,7 @@ series, shelves, storage, + support, sync, tags, users, @@ -28,6 +29,8 @@ api_router = APIRouter() +api_router.include_router(support.router, prefix="/support", tags=["Support"]) + api_router.include_router(opds.router, prefix="/opds", tags=["OPDS"]) api_router.include_router(acquisition.router, prefix="/acquisition", tags=["Acquisition"]) diff --git a/papyrus/api/routes/support.py b/papyrus/api/routes/support.py new file mode 100644 index 0000000..1db99e3 --- /dev/null +++ b/papyrus/api/routes/support.py @@ -0,0 +1,58 @@ +"""Support is available to guests as well as signed-in readers.""" + +from collections.abc import Callable, Coroutine +from typing import Annotated, Any + +from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, Response, UploadFile +from fastapi.routing import APIRoute +from pydantic import Json +from starlette.types import Message + +from papyrus.core.rate_limit import limiter +from papyrus.schemas.support import SupportRequest, SupportResponse +from papyrus.services.support import submit_support +from papyrus.services.support_attachments import MAX_REQUEST_BYTES, read_attachments + +RequestHandler = Callable[[Request], Coroutine[Any, Any, Response]] + + +@limiter.limit("3/hour;10/day") +async def handle_support_request(request: Request, handler: RequestHandler) -> Response: + """Rate-limit and bound uploads before the multipart parser spools their bodies.""" + received = 0 + + async def receive() -> Message: + nonlocal received + message = await request.receive() + received += len(message.get("body", b"")) + + if received > MAX_REQUEST_BYTES: + raise HTTPException(status_code=413, detail="Submission is too large.") + + return message + + return await handler(Request(request.scope, receive=receive)) + + +class SupportRoute(APIRoute): + def get_route_handler(self) -> RequestHandler: + handler = super().get_route_handler() + + async def limited_request(request: Request) -> Response: + response: Response = await handle_support_request(request, handler) + return response + + return limited_request + + +router = APIRouter(route_class=SupportRoute) + + +@router.post("/issues", response_model=SupportResponse, status_code=201) +def create_support_issue( + request: Request, + background_tasks: BackgroundTasks, + payload: Annotated[Json[SupportRequest], Form()], + attachments: Annotated[list[UploadFile] | None, File()] = None, +) -> SupportResponse: + return submit_support(payload, read_attachments(attachments or []), background_tasks) diff --git a/papyrus/config.py b/papyrus/config.py index 7558a32..3d2954b 100644 --- a/papyrus/config.py +++ b/papyrus/config.py @@ -1,7 +1,7 @@ from functools import lru_cache from pathlib import Path -from pydantic import computed_field, field_validator +from pydantic import Field, SecretStr, computed_field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -30,6 +30,24 @@ class Settings(BaseSettings): rate_limit_general: int rate_limit_upload: int rate_limit_batch: int + support_github_token: SecretStr | None = None + + support_github_repository: str = Field( + default="PapyrusReader/client", + pattern=r"^[A-Za-z0-9-]+/[A-Za-z0-9][A-Za-z0-9_.-]*$", + ) + + support_github_assignee: str = Field(default="Eoic", pattern=r"^[A-Za-z0-9-]+$") + support_github_project_owner: str = Field(default="PapyrusReader", pattern=r"^[A-Za-z0-9-]+$") + support_github_project_number: int = Field(default=1, gt=0) + support_github_bug_label: str = Field(default="bug", min_length=1) + support_github_feature_label: str = Field(default="enhancement", min_length=1) + support_github_bug_type: str = Field(default="Bug", min_length=1) + support_github_feature_type: str = Field(default="Feature", min_length=1) + support_github_status_field: str = Field(default="Status", min_length=1) + support_github_status: str = Field(default="Backlog", min_length=1) + support_github_kind_field: str = Field(default="Kind", min_length=1) + support_github_kind: str = Field(default="Task", min_length=1) public_base_url: str | None = None app_public_base_url: str | None = None google_oauth_client_id: str | None = None @@ -117,6 +135,7 @@ def normalize_dev_pages_vite_url(cls, value: str) -> str: return value.strip().rstrip("/") @field_validator( + "support_github_token", "public_base_url", "app_public_base_url", "google_oauth_client_id", diff --git a/papyrus/schemas/support.py b/papyrus/schemas/support.py new file mode 100644 index 0000000..9658fbb --- /dev/null +++ b/papyrus/schemas/support.py @@ -0,0 +1,20 @@ +"""Public support submissions contain only text explicitly supplied by the user.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class SupportRequest(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") + + kind: Literal["bug", "feature"] + title: str = Field(min_length=1, max_length=150) + description: str = Field(min_length=10, max_length=8000) + app_version: str = Field(min_length=1, max_length=80) + platform: str = Field(min_length=1, max_length=80) + + +class SupportResponse(BaseModel): + number: int = Field(gt=0) + url: str diff --git a/papyrus/services/support.py b/papyrus/services/support.py new file mode 100644 index 0000000..4b44949 --- /dev/null +++ b/papyrus/services/support.py @@ -0,0 +1,314 @@ +"""Submit user-reviewed reports using server-controlled GitHub routing and credentials.""" + +import json +import logging +import re +from dataclasses import dataclass +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from fastapi import BackgroundTasks, HTTPException + +from papyrus.config import get_settings +from papyrus.schemas.support import SupportRequest, SupportResponse +from papyrus.services.support_attachments import SupportAttachment + +logger = logging.getLogger(__name__) + +PROJECT_QUERY = """ +query SupportProject($owner: String!, $number: Int!, $statusField: String!, $kindField: String!) { + organization(login: $owner) { + projectV2(number: $number) { + id + viewerCanUpdate + status: field(name: $statusField) { + ... on ProjectV2SingleSelectField { id options { id name } } + } + kind: field(name: $kindField) { + ... on ProjectV2SingleSelectField { id options { id name } } + } + } + } +} +""" + + +@dataclass(frozen=True) +class SupportProject: + id: str + status_field: str + status_option: str + kind_field: str + kind_option: str + + +def submit_support( + payload: SupportRequest, + attachments: list[SupportAttachment], + background_tasks: BackgroundTasks, +) -> SupportResponse: + settings = get_settings() + token = settings.support_github_token + repository = settings.support_github_repository + + if token is None: + raise HTTPException(status_code=503, detail="Support submissions are not configured on this server.") + + if payload.kind == "bug": + category = "Bug report" + prefix = "BUG" + label = settings.support_github_bug_label + issue_type = settings.support_github_bug_type + else: + category = "Feature request" + prefix = "FEATURE" + label = settings.support_github_feature_label + issue_type = settings.support_github_feature_type + + body = ( + f"## {category}\n\n{payload.description}\n\n" + f"## App details\n\nVersion: {payload.app_version}\nPlatform: {payload.platform}" + ) + + headers = { + "Authorization": f"Bearer {token.get_secret_value()}", + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + "User-Agent": "Papyrus-Support", + "X-GitHub-Api-Version": "2022-11-28", + } + + project = load_support_project(headers) + + if attachments: + repository_details = github_request(Request(f"https://api.github.com/repos/{repository}", headers=headers)) + repository_id = repository_details.get("id") + + if type(repository_id) is not int or repository_id <= 0: + raise HTTPException(status_code=502, detail="Submission failed.") + + body += "\n\n## Attachments" + + for attachment in attachments: + query = urlencode( + { + "name": attachment.name, + "content_type": attachment.content_type, + "repository_id": repository_id, + } + ) + + asset = github_request( + Request( + f"https://uploads.github.com/user-attachments/assets?{query}", + data=attachment.data, + headers={**headers, "Content-Type": "application/octet-stream"}, + method="POST", + ) + ) + + url = asset.get("url") + + if not isinstance(url, str) or not re.fullmatch( + r"https://github\.com/user-attachments/assets/[a-zA-Z0-9-]+", url + ): + raise HTTPException(status_code=502, detail="Submission failed.") + + if attachment.content_type.startswith("image/"): + body += f"\n\n![{attachment.name}]({url})" + else: + body += f"\n\n{url}" + + result = github_request( + Request( + f"https://api.github.com/repos/{repository}/issues", + data=json.dumps( + { + "title": f"[{prefix}] {payload.title}", + "body": body, + "labels": [label], + "type": issue_type, + "assignees": [settings.support_github_assignee], + } + ).encode(), + headers=headers, + method="POST", + ) + ) + + number = result.get("number") + + if not isinstance(number, int) or isinstance(number, bool) or number <= 0: + raise HTTPException(status_code=504, detail="Submission failed.") + + background_tasks.add_task(route_support_issue, headers, project, result.get("node_id"), repository, number) + return SupportResponse(number=number, url=f"https://github.com/{repository}/issues/{number}") + + +def route_support_issue( + headers: dict[str, str], + project: SupportProject, + issue_id: object, + repository: str, + number: int, +) -> None: + """Apply best-effort project metadata after the confirmed issue response has been sent.""" + try: + add_support_to_project(headers, project, required_id(issue_id)) + except HTTPException as error: + logger.error( + "Support issue %s#%s was created but project routing failed: %s Reconcile its project fields.", + repository, + number, + error.detail, + ) + + +def required_object(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise HTTPException(status_code=502, detail="Submission failed.") + + return value + + +def required_id(value: object) -> str: + if not isinstance(value, str) or not value: + raise HTTPException(status_code=502, detail="Submission failed.") + + return value + + +def project_selection(value: object, option_name: str) -> tuple[str, str]: + field = required_object(value) + options = field.get("options") + + if isinstance(options, list): + for option in options: + if isinstance(option, dict) and option.get("name") == option_name: + return required_id(field.get("id")), required_id(option.get("id")) + + raise HTTPException(status_code=502, detail="Submission failed.") + + +def load_support_project(headers: dict[str, str]) -> SupportProject: + settings = get_settings() + + data = github_graphql( + headers, + PROJECT_QUERY, + { + "owner": settings.support_github_project_owner, + "number": settings.support_github_project_number, + "statusField": settings.support_github_status_field, + "kindField": settings.support_github_kind_field, + }, + ) + organization = required_object(data.get("organization")) + project = required_object(organization.get("projectV2")) + + if project.get("viewerCanUpdate") is not True: + raise HTTPException(status_code=503, detail="Submission failed.") + + status_field, status_option = project_selection(project.get("status"), settings.support_github_status) + kind_field, kind_option = project_selection(project.get("kind"), settings.support_github_kind) + + return SupportProject( + id=required_id(project.get("id")), + status_field=status_field, + status_option=status_option, + kind_field=kind_field, + kind_option=kind_option, + ) + + +def add_support_to_project(headers: dict[str, str], project: SupportProject, issue_id: str) -> None: + """Only mutate project metadata after creation, so a routing failure cannot invite duplicate reports.""" + data = github_graphql( + headers, + """ + mutation AddSupportToProject($project: ID!, $issue: ID!) { + addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) { item { id } } + } + """, + {"project": project.id, "issue": issue_id}, + ) + + added = required_object(data.get("addProjectV2ItemById")) + item = required_object(added.get("item")) + + github_graphql( + headers, + """ + mutation RouteSupport( + $project: ID!, $item: ID!, $status: ID!, $statusOption: String!, $kind: ID!, $kindOption: String! + ) { + status: updateProjectV2ItemFieldValue(input: { + projectId: $project, itemId: $item, fieldId: $status, value: {singleSelectOptionId: $statusOption} + }) { projectV2Item { id } } + kind: updateProjectV2ItemFieldValue(input: { + projectId: $project, itemId: $item, fieldId: $kind, value: {singleSelectOptionId: $kindOption} + }) { projectV2Item { id } } + } + """, + { + "project": project.id, + "item": required_id(item.get("id")), + "status": project.status_field, + "statusOption": project.status_option, + "kind": project.kind_field, + "kindOption": project.kind_option, + }, + ) + + +def github_graphql(headers: dict[str, str], query: str, variables: dict[str, object]) -> dict[str, object]: + result = github_request( + Request( + "https://api.github.com/graphql", + data=json.dumps({"query": query, "variables": variables}).encode(), + headers=headers, + method="POST", + ) + ) + + errors = result.get("errors") + + if isinstance(errors, list) and any( + isinstance(error, dict) and error.get("type") == "FORBIDDEN" for error in errors + ): + raise HTTPException( + status_code=502, + detail="GitHub denied project access. Check SUPPORT_GITHUB_TOKEN organization Projects write permission.", + ) + + if errors: + raise HTTPException(status_code=502, detail="Submission failed.") + + return required_object(result.get("data")) + + +def github_request(request: Request) -> dict[str, object]: + try: + with urlopen(request, timeout=30) as response: + result = json.load(response) + except HTTPError as error: + if error.code >= 500: + raise HTTPException( + status_code=504, + detail="Submission could not be confirmed. Check the repository before submitting again.", + ) from error + + raise HTTPException( + status_code=502, detail="GitHub could not accept the submission. Please try later." + ) from error + except (URLError, TimeoutError, OSError, ValueError) as error: + raise HTTPException( + status_code=504, + detail="Submission could not be confirmed. Check the repository before submitting again.", + ) from error + + if not isinstance(result, dict): + raise HTTPException(status_code=504, detail="Submission failed.") + + return result diff --git a/papyrus/services/support_attachments.py b/papyrus/services/support_attachments.py new file mode 100644 index 0000000..57cee02 --- /dev/null +++ b/papyrus/services/support_attachments.py @@ -0,0 +1,70 @@ +"""Validate support media before any bytes are sent to GitHub.""" + +from dataclasses import dataclass + +from fastapi import HTTPException, UploadFile + +MAX_ATTACHMENTS = 5 +MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024 +MAX_REQUEST_BYTES = MAX_ATTACHMENTS * MAX_ATTACHMENT_BYTES + 64 * 1024 +MEDIA_TYPES = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "webp": "image/webp", + "mp4": "video/mp4", + "mov": "video/quicktime", + "webm": "video/webm", +} + + +@dataclass(frozen=True) +class SupportAttachment: + name: str + content_type: str + data: bytes + + +def read_attachments(files: list[UploadFile]) -> list[SupportAttachment]: + if len(files) > MAX_ATTACHMENTS: + raise HTTPException(status_code=422, detail="Attach up to five files.") + + attachments = [] + + for index, file in enumerate(files, start=1): + extension = (file.filename or "").rsplit(".", 1)[-1].lower() + content_type = MEDIA_TYPES.get(extension) + + if content_type is None: + raise HTTPException(status_code=422, detail="Unsupported attachment type.") + + data = file.file.read(MAX_ATTACHMENT_BYTES + 1) + + if not data or len(data) > MAX_ATTACHMENT_BYTES: + raise HTTPException(status_code=413, detail="Attachments must be between 1 byte and 10 MB.") + + if not matches_media(data, extension): + raise HTTPException(status_code=422, detail="Invalid attachment content.") + + attachments.append(SupportAttachment(f"attachment-{index}.{extension}", content_type, data)) + + return attachments + + +def matches_media(data: bytes, extension: str) -> bool: + match extension: + case "png": + return data.startswith(b"\x89PNG\r\n\x1a\n") + case "jpg" | "jpeg": + return data.startswith(b"\xff\xd8\xff") + case "gif": + return data.startswith((b"GIF87a", b"GIF89a")) + case "webp": + return data.startswith(b"RIFF") and data[8:12] == b"WEBP" + case "mp4" | "mov": + return len(data) >= 12 and data[4:8] == b"ftyp" + case "webm": + return data.startswith(b"\x1a\x45\xdf\xa3") and b"webm" in data[:4096] + case _: + return False diff --git a/tests/api/routes/test_support.py b/tests/api/routes/test_support.py new file mode 100644 index 0000000..d107a26 --- /dev/null +++ b/tests/api/routes/test_support.py @@ -0,0 +1,535 @@ +"""Public support contracts use a mocked GitHub connection and no database.""" + +import io +import json +from copy import deepcopy +from unittest.mock import Mock +from urllib.error import HTTPError + +import pytest +from httpx import ASGITransport, AsyncClient +from pydantic import SecretStr, ValidationError + +from papyrus.config import Settings, get_settings +from papyrus.main import create_app +from papyrus.services import support + +PAYLOAD = { + "kind": "bug", + "title": "Book does not open", + "description": "Opening an EPUB leaves an empty reader.", + "app_version": "0.0.4 (4)", + "platform": "android", +} + +PROJECT = { + "data": { + "organization": { + "projectV2": { + "id": "project-id", + "viewerCanUpdate": True, + "status": {"id": "status-field", "options": [{"id": "backlog-option", "name": "Backlog"}]}, + "kind": {"id": "kind-field", "options": [{"id": "task-option", "name": "Task"}]}, + } + } + } +} + +ISSUE = {"number": 42, "node_id": "issue-id"} +PROJECT_ITEM = {"data": {"addProjectV2ItemById": {"item": {"id": "item-id"}}}} +PROJECT_FIELDS = { + "data": {"status": {"projectV2Item": {"id": "item-id"}}, "kind": {"projectV2Item": {"id": "item-id"}}} +} + + +def github_response(value): + return io.BytesIO(json.dumps(value).encode()) + + +def github_success(request, **kwargs): + if request.full_url.endswith("/issues"): + return github_response(ISSUE) + + query = json.loads(request.data)["query"] + + if "query SupportProject" in query: + return github_response(PROJECT) + + if "mutation AddSupportToProject" in query: + return github_response(PROJECT_ITEM) + + if "mutation RouteSupport" in query: + return github_response(PROJECT_FIELDS) + + raise AssertionError(f"Unexpected GitHub request: {request.full_url}") + + +@pytest.fixture +def github(monkeypatch): + monkeypatch.setattr(get_settings(), "support_github_token", SecretStr("test-server-secret")) + connection = Mock(side_effect=github_success) + monkeypatch.setattr(support, "urlopen", connection) + return connection + + +@pytest.mark.parametrize("kind", ["bug", "feature"]) +async def test_guest_submission_creates_client_issue_without_forwarding_identity(github, kind): + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps({**PAYLOAD, "kind": kind})}, + headers={"Cookie": "private-session", "Authorization": "Bearer private-user-token"}, + ) + + assert response.status_code == 201 + assert response.json() == {"number": 42, "url": "https://github.com/PapyrusReader/client/issues/42"} + request = github.call_args_list[1].args[0] + assert request.full_url == "https://api.github.com/repos/PapyrusReader/client/issues" + assert request.get_header("Authorization") == "Bearer test-server-secret" + assert request.get_header("Cookie") is None + posted = json.loads(request.data) + assert PAYLOAD["description"] in posted["body"] + assert PAYLOAD["app_version"] in posted["body"] + prefix = "BUG" if kind == "bug" else "FEATURE" + assert posted["title"] == f"[{prefix}] {PAYLOAD['title']}" + assert posted["labels"] == ["bug" if kind == "bug" else "enhancement"] + assert posted["type"] == ("Bug" if kind == "bug" else "Feature") + assert posted["assignees"] == ["Eoic"] + assert "milestone" not in posted + project_request = json.loads(github.call_args_list[2].args[0].data) + assert project_request["variables"] == {"project": "project-id", "issue": "issue-id"} + field_request = json.loads(github.call_args_list[3].args[0].data) + + assert field_request["variables"] == { + "project": "project-id", + "item": "item-id", + "status": "status-field", + "statusOption": "backlog-option", + "kind": "kind-field", + "kindOption": "task-option", + } + + assert "Submitted from Papyrus." not in posted["body"] + assert "test-server-secret" not in response.text + + +async def test_disabled_support_does_not_contact_github(monkeypatch): + monkeypatch.setattr(get_settings(), "support_github_token", None) + connection = Mock() + monkeypatch.setattr(support, "urlopen", connection) + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)} + ) + + assert response.status_code == 503 + connection.assert_not_called() + + +@pytest.mark.parametrize( + "changes", [{"kind": "other"}, {"title": " "}, {"description": "x" * 8001}, {"repository": "other/repo"}] +) +async def test_invalid_submission_is_rejected_before_github(github, changes): + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps({**PAYLOAD, **changes})} + ) + + assert response.status_code == 422 + github.assert_not_called() + + +async def test_support_rate_limits_guest_submissions(github): + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + responses = [ + await client.post(f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)}) + for _ in range(4) + ] + + assert [response.status_code for response in responses] == [201, 201, 201, 429] + assert sum(call.args[0].full_url.endswith("/issues") for call in github.call_args_list) == 3 + + +@pytest.mark.parametrize( + "error,status", + [(TimeoutError(), 504), (HTTPError("https://api.github.com", 403, "secret upstream error", {}, None), 502)], +) +async def test_provider_failure_is_sanitized_and_never_retried(github, error, status): + github.side_effect = error + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)} + ) + + assert response.status_code == status + assert "secret upstream error" not in response.text + github.assert_called_once() + + +async def test_malformed_github_success_is_an_uncertain_submission(github): + github.side_effect = [github_response(PROJECT), io.BytesIO(b'{"number": false}')] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)} + ) + + assert response.status_code == 504 + assert response.json()["detail"] == "Submission failed." + assert github.call_count == 2 + + +PNG = b"\x89PNG\r\n\x1a\n" + b"image-bytes" +MP4 = b"\x00\x00\x00\x18ftypmp42" + b"video-bytes" +ASSET_URL = "https://github.com/user-attachments/assets/11111111-2222-3333-4444-555555555555" + + +@pytest.mark.parametrize("timeout_stage", [None, "AddSupportToProject", "RouteSupport"]) +async def test_confirmed_response_is_sent_before_project_mutations_with_five_attachments(github, timeout_stage): + messages = [] + app = create_app() + + async def observed_app(scope, receive, send): + async def capture_send(message): + await send(message) + messages.append(message) + + await app(scope, receive, capture_send) + + def respond(request, **kwargs): + if request.full_url == "https://api.github.com/repos/PapyrusReader/client": + return github_response({"id": 123}) + + if request.full_url.startswith("https://uploads.github.com/"): + return github_response({"url": ASSET_URL}) + + if request.full_url.endswith("/graphql"): + query = json.loads(request.data)["query"] + + if "mutation " in query: + assert messages[0]["type"] == "http.response.start" + assert messages[0]["status"] == 201 + assert messages[-1]["type"] == "http.response.body" + assert not messages[-1].get("more_body", False) + body = b"".join(message.get("body", b"") for message in messages) + + assert json.loads(body) == { + "number": 42, + "url": "https://github.com/PapyrusReader/client/issues/42", + } + + if timeout_stage and timeout_stage in query: + raise TimeoutError() + + return github_success(request, **kwargs) + + github.side_effect = respond + + async with AsyncClient(transport=ASGITransport(app=observed_app), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps(PAYLOAD)}, + files=[("attachments", ("screen.png", PNG))] * 5, + ) + + assert response.status_code == 201 + requests = [call.args[0] for call in github.call_args_list] + assert sum(request.full_url.startswith("https://uploads.github.com/") for request in requests) == 5 + assert sum(request.full_url.endswith("/issues") for request in requests) == 1 + assert len(requests) == (9 if timeout_stage == "AddSupportToProject" else 10) + + +async def test_media_is_uploaded_to_github_and_embedded_in_issue(github): + github.side_effect = [ + github_response(PROJECT), + io.BytesIO(b'{"id": 123}'), + io.BytesIO(json.dumps({"url": ASSET_URL}).encode()), + io.BytesIO(json.dumps({"url": ASSET_URL}).encode()), + github_response(ISSUE), + github_response(PROJECT_ITEM), + github_response(PROJECT_FIELDS), + ] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps(PAYLOAD)}, + files=[("attachments", ("private-screenshot.png", PNG)), ("attachments", ("demo.mp4", MP4))], + ) + + assert response.status_code == 201 + requests = [call.args[0] for call in github.call_args_list[1:5]] + assert requests[0].full_url == "https://api.github.com/repos/PapyrusReader/client" + assert requests[1].full_url.startswith("https://uploads.github.com/user-attachments/assets?") + assert "repository_id=123" in requests[1].full_url + assert "name=attachment-1.png" in requests[1].full_url + assert requests[1].data == PNG + assert requests[2].data == MP4 + assert requests[1].get_header("Content-type") == "application/octet-stream" + body = json.loads(requests[-1].data)["body"] + assert f"![attachment-1.png]({ASSET_URL})" in body + assert f"\n\n{ASSET_URL}" in body + assert "private-screenshot" not in body + + +@pytest.mark.parametrize( + "files,status", + [ + ([("attachments", ("screen.png", PNG))] * 6, 422), + ([("attachments", ("screen.svg", b""))], 422), + ([("attachments", ("screen.png", b"not an image"))], 422), + ([("attachments", ("empty.mp4", b""))], 413), + ([("attachments", ("large.png", PNG + b"x" * (10 * 1024 * 1024)))], 413), + ], +) +async def test_invalid_media_is_rejected_before_any_github_upload(github, files, status): + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps(PAYLOAD)}, + files=files, + ) + + assert response.status_code == status + github.assert_not_called() + + +async def test_failed_attachment_upload_never_creates_text_only_issue(github): + github.side_effect = [github_response(PROJECT), io.BytesIO(b'{"id": 123}'), TimeoutError()] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps(PAYLOAD)}, + files=[("attachments", ("screen.png", PNG))], + ) + + assert response.status_code == 504 + assert github.call_count == 3 + + +async def test_attachment_response_cannot_inject_markdown_or_external_urls(github): + github.side_effect = [ + github_response(PROJECT), + io.BytesIO(b'{"id": 123}'), + io.BytesIO(b'{"url": "https://evil.example/image"}'), + ] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps(PAYLOAD)}, + files=[("attachments", ("screen.png", PNG))], + ) + + assert response.status_code == 502 + assert github.call_count == 3 + + +async def test_body_is_bounded_even_without_content_length(github, monkeypatch): + from papyrus.api.routes import support as support_routes + + monkeypatch.setattr(support_routes, "MAX_REQUEST_BYTES", 32) + + async def body(): + yield b"--boundary\r\nContent-Disposition: form-data; name=payload\r\n\r\n" + yield b"x" * 64 + yield b"\r\n--boundary--\r\n" + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + content=body(), + headers={"Content-Type": "multipart/form-data; boundary=boundary"}, + ) + + assert response.status_code == 413 + github.assert_not_called() + + +async def test_rate_limit_rejects_upload_without_reading_body(github): + async def unread_body(): + pytest.fail("A rate-limited request must not consume attachment bytes") + yield b"" + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + for _ in range(3): + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)} + ) + assert response.status_code == 201 + + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + content=unread_body(), + headers={"Content-Type": "multipart/form-data; boundary=boundary"}, + ) + + assert response.status_code == 429 + assert sum(call.args[0].full_url.endswith("/issues") for call in github.call_args_list) == 3 + + +@pytest.mark.parametrize("failure", ["permissions", "missing-option", "graphql"]) +async def test_project_configuration_is_checked_before_creating_an_issue_or_uploading(github, failure): + project = deepcopy(PROJECT) + + if failure == "permissions": + project["data"]["organization"]["projectV2"]["viewerCanUpdate"] = False + elif failure == "missing-option": + project["data"]["organization"]["projectV2"]["status"]["options"] = [] + else: + project = {"errors": [{"message": "private upstream details"}]} + + github.side_effect = [github_response(project)] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", + data={"payload": json.dumps(PAYLOAD)}, + files=[("attachments", ("screen.png", PNG))], + ) + + assert response.status_code in (502, 503) + assert response.json()["detail"] == "Submission failed." + github.assert_called_once() + + +@pytest.mark.parametrize("stage", ["add", "fields"]) +@pytest.mark.parametrize("failure", ["timeout", "graphql", "forbidden"]) +async def test_project_failure_after_creation_keeps_success_and_logs_issue_for_reconciliation( + github, caplog, stage, failure +): + responses = [github_response(PROJECT), github_response(ISSUE)] + + if stage == "fields": + responses.append(github_response(PROJECT_ITEM)) + + if failure == "timeout": + responses.append(TimeoutError()) + elif failure == "forbidden": + responses.append( + github_response( + { + "data": {"addProjectV2ItemById": None}, + "errors": [{"type": "FORBIDDEN", "message": "private upstream details"}], + } + ) + ) + else: + responses.append(github_response({"errors": [{"message": "private upstream details"}]})) + + github.side_effect = responses + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)} + ) + + assert response.status_code == 201 + assert response.json()["number"] == 42 + assert sum(call.args[0].full_url.endswith("/issues") for call in github.call_args_list) == 1 + assert github.call_count == len(responses) + assert "PapyrusReader/client#42" in caplog.text + assert "private upstream details" not in caplog.text + assert "test-server-secret" not in caplog.text + + if failure == "forbidden": + assert "SUPPORT_GITHUB_TOKEN organization Projects write permission" in caplog.text + + +async def test_issue_creation_failure_is_not_retried_or_added_to_project(github): + github.side_effect = [github_response(PROJECT), TimeoutError()] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{get_settings().api_prefix}/support/issues", data={"payload": json.dumps(PAYLOAD)} + ) + + assert response.status_code == 504 + assert github.call_count == 2 + + +@pytest.mark.parametrize("kind", ["bug", "feature"]) +async def test_support_routing_uses_server_environment(github, monkeypatch, kind): + overrides = { + "support_github_repository": "ExampleOrg/reader-support", + "support_github_assignee": "maintainer", + "support_github_project_owner": "ExampleOrg", + "support_github_project_number": "7", + "support_github_bug_label": "defect", + "support_github_feature_label": "suggestion", + "support_github_bug_type": "Problem", + "support_github_feature_type": "Idea", + "support_github_status_field": "Workflow", + "support_github_status": "Triage", + "support_github_kind_field": "Category", + "support_github_kind": "Feedback", + } + + for name, value in overrides.items(): + monkeypatch.setenv(name.upper(), value) + + settings = Settings(**get_settings().model_dump(include=set(Settings.model_fields) - set(overrides))) + monkeypatch.setattr(support, "get_settings", lambda: settings) + project = deepcopy(PROJECT) + project["data"]["organization"]["projectV2"]["status"]["options"][0]["name"] = "Triage" + project["data"]["organization"]["projectV2"]["kind"]["options"][0]["name"] = "Feedback" + + github.side_effect = [ + github_response(project), + github_response({"id": 123}), + github_response({"url": ASSET_URL}), + github_response(ISSUE), + github_response(PROJECT_ITEM), + github_response(PROJECT_FIELDS), + ] + + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: + response = await client.post( + f"{settings.api_prefix}/support/issues", + data={"payload": json.dumps({**PAYLOAD, "kind": kind})}, + files=[("attachments", ("screen.png", PNG))], + ) + + assert response.status_code == 201 + assert response.json()["url"] == "https://github.com/ExampleOrg/reader-support/issues/42" + requests = [call.args[0] for call in github.call_args_list] + + assert json.loads(requests[0].data)["variables"] == { + "owner": "ExampleOrg", + "number": 7, + "statusField": "Workflow", + "kindField": "Category", + } + + assert requests[1].full_url == "https://api.github.com/repos/ExampleOrg/reader-support" + assert requests[3].full_url == "https://api.github.com/repos/ExampleOrg/reader-support/issues" + posted = json.loads(requests[3].data) + assert posted["assignees"] == ["maintainer"] + assert posted["labels"] == ["defect" if kind == "bug" else "suggestion"] + assert posted["type"] == ("Problem" if kind == "bug" else "Idea") + assert json.loads(requests[5].data)["variables"]["statusOption"] == "backlog-option" + assert json.loads(requests[5].data)["variables"]["kindOption"] == "task-option" + + +@pytest.mark.parametrize( + "name,value", + [ + ("support_github_repository", "https://example.com/repo"), + ("support_github_repository", "ExampleOrg/../repo"), + ("support_github_project_number", 0), + ("support_github_assignee", ""), + ("support_github_project_owner", "org/other"), + ("support_github_status", ""), + ], +) +def test_invalid_support_routing_configuration_is_rejected(name, value): + values = get_settings().model_dump(include=set(Settings.model_fields)) + values[name] = value + + with pytest.raises(ValidationError) as error: + Settings(**values) + + assert [entry["loc"] for entry in error.value.errors()] == [(name,)]