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
18 changes: 15 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,8 @@ fetch(source) ──► stored copy fresh for source.maxAgeMs? ──yes──
SharedDownload for the key in flight? ──yes──► join it
│ no
▼
GET (conditional with the copy's ETag / Last-Modified)
GET (conditional with the copy's ETag / Last-Modified),
following redirects by RedirectPolicy
304 → mark validated · 200 + %PDF- → rename into place
unreachable / 5xx → view: stored copy as stale · PdfCache: error
```
Expand All @@ -457,10 +458,21 @@ fetch(source) ──► stored copy fresh for source.maxAgeMs? ──yes──
old ones and removal unlinks, so a displayed document keeps reading its
file. iOS views open a pin (hard link in `BetterPdf/open`) because they
re-open the displayed file by path for outline, page sizes and images.
- **Redirects:** `RedirectPolicy` (Kotlin and Swift, same rules): at most
10 (then `network-error`), never https → http (`http-error` with the
3xx), caller headers only until the first hop to another origin; the
conditional headers go on every hop. Neither platform's own handling
fits: AOSP's OkHttp refuses scheme changes and sends every header but
`Authorization` to other hosts, so Android follows redirects itself
(`instanceFollowRedirects = false`, one connection per hop, each one
reachable by `cancel()`); CFNetwork drops `Authorization` on every
redirect and keeps the other headers on every host, so iOS rebuilds each
redirected request in a task delegate.
- **Waiting:** one subscriber per view load or `PdfCache.download()`.
Cancelling removes the subscriber; the request stops when none remain.
- **Tests:** `DocumentCacheTest` (JVM, local HTTP server) and the
`viewer-offline` e2e flow on both platforms.
- **Tests:** `DocumentCacheTest` (JVM, local HTTP server),
`RedirectPolicyTest`, and the `viewer-offline` and `viewer-redirects` e2e
flows on both platforms.

## Known platform constraints

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ all props and ref methods; the [guides](#guides) cover each feature.

| Guide | Covers |
| --- | --- |
| [Sources and loading](docs/guides/sources-and-loading.md) | Remote, local and bundled files, request headers, base64 data, several files as one document, loading state |
| [Sources and loading](docs/guides/sources-and-loading.md) | Remote, local and bundled files, request headers and redirects, base64 data, several files as one document, loading state |
| [Navigation and reading position](docs/guides/navigation.md) | Links and page taps; saving and restoring where the reader left off |
| [Offline reading and caching](docs/guides/offline-and-caching.md) | Cache modes, revalidation, offline fallback, `PdfCache` |
| [Layout and appearance](docs/guides/layout-and-appearance.md) | Facing pages, translucent bars, dark pages |
Expand Down
22 changes: 22 additions & 0 deletions benchmarks/serve-fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
// `GET /__control/offline` makes the server drop every other connection
// without answering (a device without network, as far as the app can tell)
// until `GET /__control/online`.
//
// Redirects: `/__redirect?to=<url>` answers 302 to `<url>`, and
// `/__redirect-loop` redirects to itself. `?require=a,b` answers 401 unless
// the request has all of these headers, `?forbid=a,b` 403 if it has any.

import { createReadStream, statSync } from 'node:fs';
import { createServer } from 'node:http';
Expand Down Expand Up @@ -61,6 +65,24 @@ createServer((request, response) => {
request.socket.destroy();
return;
}
if (url.pathname === '/__redirect' || url.pathname === '/__redirect-loop') {
const location = url.searchParams.get('to') ?? url.pathname;
response.writeHead(302, { Location: location }).end();
console.log(`${request.method} ${url.pathname}${url.search} → ${location}`);
return;
}
const names = (key) =>
(url.searchParams.get(key) ?? '').split(',').filter(Boolean);
const status = names('require').some((n) => !(n in request.headers))
? 401
: names('forbid').some((n) => n in request.headers)
? 403
: null;
if (status !== null) {
response.writeHead(status).end();
console.log(`${request.method} ${url.pathname}${url.search} → ${status}`);
return;
}
const name = basename(decodeURIComponent(url.pathname));
const file = join(root, name);
try {
Expand Down
21 changes: 18 additions & 3 deletions docs/api-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,20 @@ invalidation, prefetch and offline reading (#952, #964, #572, #566, #689,
- **Only PDFs are stored.** A `200` that does not start with `%PDF-` within
1024 bytes (login page, captive portal) fails with `invalid-pdf` instead
of being kept — persistently — as the document.
- **Redirects: one policy, ours.** The platforms disagreed and both were
unsafe: Android (AOSP's OkHttp) refused http → https and sent every
header but `Authorization` on to other hosts; iOS (CFNetwork) followed
https → http and also forwarded every header but `Authorization`, which
it dropped even on the same origin. Now both follow at most 10 redirects
(`network-error` after that), follow http → https but refuse https → http
(`http-error` with the 3xx: the server answered), and send the source's
headers only while every hop stays on the URL's origin (scheme, host,
port), as the Fetch standard does with `Authorization`. A token in
`X-Api-Key` or `Cookie` is a credential too, so all headers are dropped,
for the rest of the chain. The conditional headers are ours and always
go, so a `304` from the target still validates the copy. Neither a refused
redirect nor too many redirects shows the stored copy: the server
answered. Not included: an allowlist of hosts that keep the headers.
- **One download per copy.** A list prefetching a document and a viewer
opening it share one request (`SharedDownload`); cancelling one waiter
leaves the others, and the request stops when nobody waits.
Expand All @@ -1185,9 +1199,10 @@ invalidation, prefetch and offline reading (#952, #964, #572, #566, #689,
- **Tested:** JVM tests run the Android cache against a local HTTP server
(reuse, 304, changed document, stale fallback, 5xx vs 4xx, non-PDF
responses, invalid headers, shared and cancelled downloads, `cacheKey`,
promotion, restart, cleanup). `viewer-offline` runs the same story on both
platforms with the fixture server's simulated outage
(`/__control/offline`), including an app restart.
promotion, restart, cleanup, redirects). `viewer-offline` runs the same
story on both platforms with the fixture server's simulated outage
(`/__control/offline`), including an app restart; `viewer-redirects`
checks the redirect policy on both.

## Short documents, rounded corners, safe area

Expand Down
3 changes: 3 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ pdf.current?.goToViewport(saved); // a position from onViewportChan
`invalid-password` · `unsupported-pdf` · `page-render-failed` (`error.page`) ·
`out-of-memory` (`error.page` while rendering)

A redirect that is not followed is an `http-error` with the redirect's
status; see [Redirects](guides/sources-and-loading.md#redirects).

`error.message` is human-readable and may change; `error.nativeDescription`
carries the platform error for logging. Load failures carry
`error.sourceIndex`, the source that could not be loaded (`0` for a single
Expand Down
4 changes: 3 additions & 1 deletion docs/guides/offline-and-caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ const source: PdfUriSource = {
- **Offline**: when the server cannot be reached (no connection, no answer
within 10 seconds, or a 5xx response), the view shows the stored copy and
`onLoad` reports `isStale: true`. Without a copy it fails with
`network-error`. A 4xx response is an error even with a copy.
`network-error`. A 4xx response is an error even with a copy, and so are
a redirect that is not followed and more than 10 redirects; see
[Redirects](sources-and-loading.md#redirects).
- **`cacheKey`** identifies the copy instead of `uri` and `headers`. Set it
when they change between opens — signed URLs, rotating tokens — or the
copy is not found again. Sources with the same `cacheKey` are the same
Expand Down
14 changes: 14 additions & 0 deletions docs/guides/sources-and-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ before its `password` option.
break or another control character (tab is fine): such a source fails with
`invalid-source` on both platforms, before anything is sent.

### Redirects

Both platforms follow redirects the same way:

- At most 10; more fail with `network-error`.
- From http to https, never from https to http or to anything but
http(s): that fails with `http-error`, and `httpStatus` is the
redirect's status (301, 302, 303, 307 or 308).
- `headers` go to the URL's origin (scheme, host and port) only,
`Authorization` included. After a redirect to another origin, the rest of
the download is sent without them, even if a later redirect comes back.
http to https on the same host is another origin too: when you send
credentials, use the https URL.

## Base64 documents

A document you have as a base64 string — made with pdfmake or pdf-lib, or
Expand Down
2 changes: 1 addition & 1 deletion docs/migrating-from-react-native-pdf.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ users see.
| --- | --- | --- |
| `source={{ uri }}` | `source={{ uri }}` | `http(s)://`, `file://`, `content://` (Android). |
| `source={require('./x.pdf')}` | `source={require('./x.pdf')}` | Same; no Metro config needed. |
| `source.headers` | `source.headers` | Same. |
| `source.headers` | `source.headers` | Same, but sent to the URL's origin only: a redirect to another origin drops them. See [Redirects](guides/sources-and-loading.md#redirects). |
| `source.cache: true` | `source.cache: 'persistent'` | Kept across launches, never purged by the OS. See [Caching](#caching). |
| `source.cacheFileName` | `source.cacheKey` | Identifies the copy instead of the URL and headers. |
| `source.expiration` (seconds) | `source.maxAgeMs` (milliseconds) | An older copy is revalidated with the server (304 keeps it) instead of deleted, and still shown offline. |
Expand Down
2 changes: 2 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
| On Android, `onError` arrives while the document is shown | It has `error.page`: one page could not be rendered after `onLoad` (`page-render-failed`, or `out-of-memory`), and the document stays open. Keep the viewer on screen; only `onError` without `page` ends the load. See [Errors](api.md#errors). |
| A remote document fails with `invalid-pdf` | The server answered with something else, often a login page: check the URL and `headers`. |
| A remote document fails with `invalid-source` | Check that the URL is `http(s)://` and the `headers`: often a token read with a trailing newline. `error.message` names the header. |
| A remote document fails with `http-error` and a 3xx `httpStatus` | The server redirects from https to http, which is not followed. Use a URL that stays on https. See [Redirects](guides/sources-and-loading.md#redirects). |
| A remote document fails with `http-error` 401 or 403 after a redirect | The server redirects to another origin (another host or port, or http to https), and `headers` are not sent there. Use the final URL, or one the target accepts without your headers, such as a signed URL. |
| A signed URL or rotating token downloads the document on every open | Set `cacheKey` on the source to identify the stored copy. |
| The document is a base64 string | Pass `source={{ base64 }}` (1.1+); for large documents write it to a file. See [Base64 documents](guides/sources-and-loading.md#base64-documents). |
| A base64 source fails with `invalid-source` | `error.message` says why: a `data:` URI prefix to remove, URL-safe base64 (`-`, `_`) to convert, or another character that is not base64. |
Expand Down
32 changes: 32 additions & 0 deletions example/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import type {

/** Served by `node benchmarks/serve-fixtures.mjs` (Android: `adb reverse tcp:8090 tcp:8090`). */
export const FIXTURE_SERVER = 'http://localhost:8090';
/** The same server as another origin (another host name). */
const FIXTURE_SERVER_OTHER_ORIGIN = 'http://127.0.0.1:8090';

function redirectTo(url: string): string {
return `${FIXTURE_SERVER}/__redirect?to=${encodeURIComponent(url)}`;
}

export const FIXTURES = [
'small',
Expand Down Expand Up @@ -115,6 +121,32 @@ export const DOCUMENTS: DocumentEntry[] = [
subtitle: 'Expect http-error',
resolve: async () => ({ uri: `${FIXTURE_SERVER}/does-not-exist.pdf` }),
},
{
id: 'remote-redirect',
title: 'Redirect, same origin',
subtitle: 'Authorization must reach the target',
resolve: async () => ({
uri: redirectTo('/medium.pdf?require=authorization,x-api-key'),
headers: { Authorization: 'Bearer e2e', 'X-Api-Key': 'e2e' },
}),
},
{
id: 'remote-redirect-other-origin',
title: 'Redirect, other origin',
subtitle: 'Headers must not reach the target',
resolve: async () => ({
uri: redirectTo(
`${FIXTURE_SERVER_OTHER_ORIGIN}/medium.pdf?forbid=authorization,x-api-key`,
),
headers: { Authorization: 'Bearer e2e', 'X-Api-Key': 'e2e' },
}),
},
{
id: 'remote-redirect-loop',
title: 'Redirect loop',
subtitle: 'Expect network-error',
resolve: async () => ({ uri: `${FIXTURE_SERVER}/__redirect-loop` }),
},
{
id: 'remote-forms',
title: 'Remote form',
Expand Down
2 changes: 1 addition & 1 deletion example/e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ cd "$(dirname "$0")"
device="$1"; shift
flows=("$@")
if [ ${#flows[@]} -eq 0 ]; then
flows=(viewer-bundled viewer-scroll-zoom viewer-errors viewer-password viewer-layout-modes viewer-fit-policy viewer-single-page viewer-rtl viewer-outline viewer-scroll-enabled viewer-links viewer-double-tap viewer-scale-change viewer-initial viewer-text-selection viewer-accessibility viewer-search viewer-viewport viewer-page-sizes viewer-page-image viewer-offline viewer-short-document viewer-print viewer-spreads viewer-dark viewer-overlays viewer-forms viewer-inset viewer-inset-last-page viewer-base64 viewer-sources pdf-document)
flows=(viewer-bundled viewer-scroll-zoom viewer-errors viewer-password viewer-layout-modes viewer-fit-policy viewer-single-page viewer-rtl viewer-outline viewer-scroll-enabled viewer-links viewer-double-tap viewer-scale-change viewer-initial viewer-text-selection viewer-accessibility viewer-search viewer-viewport viewer-page-sizes viewer-page-image viewer-offline viewer-short-document viewer-print viewer-spreads viewer-dark viewer-overlays viewer-forms viewer-inset viewer-inset-last-page viewer-base64 viewer-sources viewer-redirects pdf-document)
fi
failed=0
is_android=0
Expand Down
18 changes: 18 additions & 0 deletions example/e2e/viewer-redirects.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Redirects follow one policy on both platforms: the source's headers reach
# a target on the same origin (Authorization included) and never one on
# another origin (localhost → 127.0.0.1), and a loop ends with network-error.
# The fixture server answers 401 / 403 when the target sees the wrong headers.
appId: software.gmi.betterpdf.example
---
- runFlow: _start.yaml
- tapOn: "Viewer"
- runFlow: { file: _open-list-entry.yaml, env: { ENTRY: "Redirect, same origin.*" } }
- extendedWaitUntil: { visible: "1 / 100", timeout: 60000 }
- tapOn: "‹ Back"
- runFlow: { file: _open-list-entry.yaml, env: { ENTRY: "Redirect, other origin.*" } }
- extendedWaitUntil: { visible: "1 / 100", timeout: 60000 }
- tapOn: "‹ Back"
- runFlow: { file: _open-list-entry.yaml, env: { ENTRY: "Redirect loop.*" } }
- extendedWaitUntil: { visible: "network-error", timeout: 30000 }
- assertVisible: ".*more than 10 redirects.*"
- tapOn: "‹ Back"
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.margelo.nitro.betterpdf

import java.net.MalformedURLException
import java.net.URL

/**
* The redirects one download follows, the same as iOS's `RedirectPolicy`: at
* most [MAX_REDIRECTS], to `http(s)` only, never from https to http, and the
* caller's headers only until the first hop to another origin.
*/
internal class RedirectPolicy(first: URL) {
sealed interface Next {
data class Follow(val url: URL) : Next

/** The redirect response is the answer. */
data object Refuse : Next

data object TooMany : Next
}

private val origin = Origin(first)
private var followed = 0

var sendsHeaders = true
private set

fun next(from: URL, location: String?): Next {
val to = try {
URL(from, location ?: return Next.Refuse)
} catch (_: MalformedURLException) {
return Next.Refuse
}
if (to.protocol != "http" && to.protocol != "https") return Next.Refuse
if (from.protocol == "https" && to.protocol == "http") return Next.Refuse
if (followed == MAX_REDIRECTS) return Next.TooMany
followed++
if (Origin(to) != origin) sendsHeaders = false
return Next.Follow(to)
}

private data class Origin(val scheme: String, val host: String, val port: Int) {
constructor(url: URL) : this(url.protocol, url.host.lowercase(), if (url.port == -1) url.defaultPort else url.port)
}

companion object {
const val MAX_REDIRECTS = 10

/** The statuses that redirect, as in URLSession. */
val STATUSES = setOf(301, 302, 303, 307, 308)
}
}
Loading
Loading