Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions deploy/production.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 59 additions & 0 deletions docs/support.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions papyrus/api/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@
series,
shelves,
storage,
support,
sync,
tags,
users,
)

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"])
Expand Down
58 changes: 58 additions & 0 deletions papyrus/api/routes/support.py
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 20 additions & 1 deletion papyrus/config.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions papyrus/schemas/support.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading