fix(documents): read Papra as it answers

Papra organization ids are checked against org_ plus 24 characters
before anything is sent. A 403 user.not_in_organization and a 400
server.invalid_request.params read as no such organization instead of a
refused key; the error body is read for its code and never passed on.
The connection test also reads the tags, so a key without tags:read
fails at save (permissionMissing) instead of on the first filter. Name
searches quote each word, so a leading dash is not a negation, and the
date range goes to Papra as date:>= / date:<= with the per-row check
kept as a net.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 14:46:01 +02:00
1 parent c5a63934bd
commit 057281998f
17 files changed
+245 -56

No files matched your search

@@ -22,6 +22,7 @@ export function isStoppingError(code: string | undefined): boolean {
code === "documents.inbound.rateLimited" ||
code === "documents.inbound.quotaExceeded" ||
code === "documents.sources.authRefused" ||
code === "documents.sources.permissionMissing" ||
code === "documents.sources.unreachable" ||
code === "documents.sources.originNotAllowed" ||
code === "documents.sources.notConnected" ||
@@ -45,6 +46,8 @@ export function sourceErrorMessage(t: T, code: string | undefined): string {
return t("documents.sourcePicker.errors.redirected");
case "documents.sources.authRefused":
return t("documents.sourcePicker.errors.authRefused");
case "documents.sources.permissionMissing":
return t("documents.sourcePicker.errors.permissionMissing");
case "documents.sources.versionTooOld":
return t("documents.sourcePicker.errors.versionTooOld");
case "documents.sources.badResponse":
@@ -41,7 +41,7 @@ const PAPRA = {
system: "PAPRA" as const,
origin: "https://papra.example.com",
baseUrl: "https://papra.example.com",
organizationId: "org_123",
organizationId: "org_a1b2c3d4e5f6g7h8i9j0k1l2",
token: "ppk_secret",
};
@@ -383,6 +383,74 @@ describe("Paperless-ngx tags", () => {
});
});
describe("Papra, as it answers for real", () => {
it("quotes each word so a leading dash is not a negation, and sends the date range", async () => {
serve({
"/api/organizations/org_a1b2c3d4e5f6g7h8i9j0k1l2/documents": () =>
json({ documents: [], documentsCount: 0 }),
});
await sourceClient(PAPRA).search({
q: "-Knie Entlass",
tagId: null,
from: "2024-01-01",
to: "2024-12-31",
page: 1,
});
const list = calledUrls().find((u) => u.pathname.endsWith("/documents"));
expect(list?.searchParams.get("searchQuery")).toBe(
'"-Knie" "Entlass" date:>=2024-01-01 date:<=2024-12-31',
);
});
it("refuses an organization id Papra would not issue", () => {
for (const org of ["org_123", "../admin", "ORG_A1B2C3D4E5F6G7H8I9J0K1L2"]) {
expect(() => sourceClient({ ...PAPRA, organizationId: org })).toThrow(
DocumentSourceError,
);
}
expect(safeFetch).not.toHaveBeenCalled();
});
it("reads 'not in this organization' and a malformed id as no such organization", async () => {
serve({
"/api/organizations/": () =>
json({ error: { code: "user.not_in_organization" } }, { status: 403 }),
});
expect(
((await failure(sourceClient(PAPRA).test())) as DocumentSourceError).code,
).toBe("notFound");
serve({
"/api/organizations/": () =>
json(
{ error: { code: "server.invalid_request.params" } },
{ status: 400 },
),
});
expect(
((await failure(sourceClient(PAPRA).test())) as DocumentSourceError).code,
).toBe("notFound");
// Any other refusal of the key is still the key.
serve({
"/api/organizations/": () =>
json({ error: { code: "auth.unauthorized" } }, { status: 401 }),
});
expect(
((await failure(sourceClient(PAPRA).test())) as DocumentSourceError).code,
).toBe("authRefused");
});
it("fails the connection test when the key cannot read tags", async () => {
serve({
"/api/organizations/org_a1b2c3d4e5f6g7h8i9j0k1l2/tags": () =>
json({}, { status: 401 }),
"/api/organizations/org_a1b2c3d4e5f6g7h8i9j0k1l2/documents": () =>
json({ documents: [], documentsCount: 0 }),
});
const err = await failure(sourceClient(PAPRA).test());
expect((err as DocumentSourceError).code).toBe("permissionMissing");
});
});
describe("Papra", () => {
const tags = {
tags: [
@@ -393,8 +461,8 @@ describe("Papra", () => {
it("sends the name and the tag through searchQuery and re-checks both", async () => {
serve({
"/api/organizations/org_123/tags": () => json(tags),
"/api/organizations/org_123/documents": () =>
"/api/organizations/org_a1b2c3d4e5f6g7h8i9j0k1l2/tags": () => json(tags),
"/api/organizations/org_a1b2c3d4e5f6g7h8i9j0k1l2/documents": () =>
json({
documentsCount: 60,
documents: [
@@ -431,7 +499,7 @@ describe("Papra", () => {
});
const list = calledUrls().find((u) => u.pathname.endsWith("/documents"));
expect(list?.searchParams.get("searchQuery")).toBe(
'blood tag:"Lab results"',
'"blood" tag:"Lab results" date:>=2025-01-01',
);
expect(list?.searchParams.get("pageIndex")).toBe("0");
expect(list?.searchParams.get("pageSize")).toBe("25");
@@ -453,7 +521,9 @@ describe("Papra", () => {
});
it("matches nothing for a tag that no longer exists", async () => {
serve({ "/api/organizations/org_123/tags": () => json(tags) });
serve({
"/api/organizations/org_a1b2c3d4e5f6g7h8i9j0k1l2/tags": () => json(tags),
});
await expect(
sourceClient(PAPRA).search({
q: "",
@@ -469,7 +539,9 @@ describe("Papra", () => {
});
it("lists tags by name and maps an unknown organization to notFound", async () => {
serve({ "/api/organizations/org_123/tags": () => json(tags) });
serve({
"/api/organizations/org_a1b2c3d4e5f6g7h8i9j0k1l2/tags": () => json(tags),
});
await expect(sourceClient(PAPRA).tags()).resolves.toEqual([
{ id: "tag_a", name: "Health" },
{ id: "tag_b", name: "Lab results" },
@@ -482,9 +554,9 @@ describe("Papra", () => {
it("reads a document and downloads its file", async () => {
serve({
"/api/organizations/org_123/documents/doc_1/file": () =>
new Response(new Uint8Array([1, 2, 3])),
"/api/organizations/org_123/documents/doc_1": () =>
"/api/organizations/org_a1b2c3d4e5f6g7h8i9j0k1l2/documents/doc_1/file":
() => new Response(new Uint8Array([1, 2, 3])),
"/api/organizations/org_a1b2c3d4e5f6g7h8i9j0k1l2/documents/doc_1": () =>
json({
document: {
id: "doc_1",
+48 -4
View File
@@ -30,8 +30,9 @@ import {
DOCUMENT_PICKER_MAX_TAGS,
DOCUMENT_PICKER_PAGE_SIZE,
isSourceDocumentId,
PAPRA_ID,
PAPRA_ORG_ID,
type DocumentPickerSystem,
type DocumentSourceErrorCode,
type DocumentSourceTagDto,
} from "./types";
@@ -335,13 +336,33 @@ function paperless(connection: SourceConnection): SourceClient {
// ── Papra ──────────────────────────────────────────────────────────────────
/**
* Papra's error bodies carry a machine code. Two of them mean "no such
* organization" rather than "wrong key": a valid organization id the key's
* user is not a member of (403 `user.not_in_organization`), and one Papra
* cannot parse (400 `server.invalid_request.params`).
*/
function papraError(
status: number,
code: string | null,
): DocumentSourceErrorCode | null {
if (status === 403 && code === "user.not_in_organization") return "notFound";
if (status === 400 && code === "server.invalid_request.params") {
return "notFound";
}
return null;
}
/** Papra's search syntax quotes a tag name that holds a space. */
function papraTagTerm(name: string): string {
return /[\s"]/.test(name) ? `tag:${JSON.stringify(name)}` : `tag:${name}`;
}
function papra(connection: SourceConnection): SourceClient {
if (!connection.organizationId || !PAPRA_ID.test(connection.organizationId)) {
if (
!connection.organizationId ||
!PAPRA_ORG_ID.test(connection.organizationId)
) {
throw new DocumentSourceError("notFound");
}
const org = `/api/organizations/${encodeURIComponent(connection.organizationId)}`;
@@ -352,6 +373,7 @@ function papra(connection: SourceConnection): SourceClient {
Authorization: `Bearer ${connection.token}`,
Accept: "application/json",
},
classify: papraError,
};
const download: SourceTarget = {
...target,
@@ -384,13 +406,31 @@ function papra(connection: SourceConnection): SourceClient {
`${org}/documents?pageIndex=0&pageSize=1`,
);
if (!asRecord(body)) throw shapeError();
// The tag filter needs `tags:read`; a key without it would save green
// and then fail on the first search.
try {
await sourceJson(target, `${org}/tags`);
} catch (err) {
if (err instanceof DocumentSourceError && err.code === "authRefused") {
throw new DocumentSourceError(
"permissionMissing",
err.upstreamStatus,
);
}
throw err;
}
},
tags: listTags,
async search(params) {
const terms: string[] = [];
if (params.q) terms.push(params.q);
// Each word quoted: Papra reads a leading `-` as a negation and a `:` as
// a field, and a quoted word still matches as a prefix.
const terms: string[] = params.q
.split(/\s+/)
.map((word) => word.replace(/"/g, ""))
.filter((word) => word.length > 0)
.map((word) => `"${word}"`);
if (params.tagId) {
const tag = (await listTags()).find((t) => t.id === params.tagId);
// A tag that no longer exists matches nothing, rather than silently
@@ -398,6 +438,10 @@ function papra(connection: SourceConnection): SourceClient {
if (!tag) return { items: [], hasMore: false };
terms.push(papraTagTerm(tag.name));
}
// The range goes to Papra as its own `date:` filter; the check on each
// row below stays as the net for a version that reads it differently.
if (params.from) terms.push(`date:>=${params.from}`);
if (params.to) terms.push(`date:<=${params.to}`);
const pageIndex = params.page - 1;
const query = new URLSearchParams({
pageIndex: String(pageIndex),
+56 -4
View File
@@ -62,6 +62,8 @@ export interface SourceTarget {
/** Origin plus the optional base path, no trailing slash. */
baseUrl: string;
headers: Record<string, string>;
/** How this source's error bodies read, when they say more than a status. */
classify?: ErrorClassifier;
}
async function sourceRequest(
@@ -103,13 +105,54 @@ async function discard(response: Response): Promise<void> {
}
}
/**
* A source-specific reading of an error answer: given the status and the
* machine code in the body (if any), the error to report, or null for the
* generic mapping. The body itself is never passed on.
*/
export type ErrorClassifier = (
status: number,
code: string | null,
) => DocumentSourceErrorCode | null;
/** Longest error body read to find its machine code. */
const ERROR_BODY_MAX_BYTES = 4 * 1024;
/** The `code` (or `error.code`) of a JSON error body, if there is one. */
async function errorCodeOf(response: Response): Promise<string | null> {
try {
const bytes = await readBoundedBody(response.body, ERROR_BODY_MAX_BYTES);
const body = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
if (!body || typeof body !== "object") return null;
const record = body as Record<string, unknown>;
const nested = record.error as Record<string, unknown> | undefined;
const code =
typeof record.code === "string"
? record.code
: nested &&
typeof nested === "object" &&
typeof nested.code === "string"
? nested.code
: null;
return code && code.length <= 100 ? code : null;
} catch {
return null;
}
}
/** Map a non-2xx answer to the error the person sees. */
export async function failFor(
response: Response,
notFound: DocumentSourceErrorCode = "notFound",
classify?: ErrorClassifier,
): Promise<never> {
await discard(response);
const status = response.status;
if (classify) {
const specific = classify(status, await errorCodeOf(response));
if (specific) throw new DocumentSourceError(specific, status);
} else {
await discard(response);
}
if (status === 401 || status === 403) {
throw new DocumentSourceError("authRefused", status);
}
@@ -124,10 +167,19 @@ export async function failFor(
export async function sourceJson(
target: SourceTarget,
path: string,
options: { notFound?: DocumentSourceErrorCode } = {},
options: {
notFound?: DocumentSourceErrorCode;
classify?: ErrorClassifier;
} = {},
): Promise<{ body: unknown; response: Response }> {
const response = await sourceRequest(target, path, SOURCE_JSON_TIMEOUT_MS);
if (!response.ok) return failFor(response, options.notFound);
if (!response.ok) {
return failFor(
response,
options.notFound,
options.classify ?? target.classify,
);
}
const declared = Number(response.headers.get("content-length") ?? 0);
if (declared > SOURCE_JSON_MAX_BYTES) {
await discard(response);
@@ -162,7 +214,7 @@ export async function sourceDownload(
path,
SOURCE_DOWNLOAD_TIMEOUT_MS,
);
if (!response.ok) return failFor(response);
if (!response.ok) return failFor(response, "notFound", target.classify);
const declared = Number(response.headers.get("content-length") ?? 0);
if (declared > maxFileBytes) {
await discard(response);
@@ -83,6 +83,7 @@ const STATUS: Record<DocumentSourceErrorCode, number> = {
unreachable: 502,
redirected: 502,
authRefused: 502,
permissionMissing: 502,
versionTooOld: 502,
badResponse: 502,
notFound: 404,
@@ -99,6 +100,8 @@ const MESSAGE: Record<DocumentSourceErrorCode, string> = {
redirected:
"The document archive redirected the request. Use the address it redirects to.",
authRefused: "The document archive refused the API token.",
permissionMissing:
"The API key cannot read the archive's tags. Give it the tags:read permission.",
versionTooOld: "The document archive's version is too old.",
badResponse: "The document archive gave an answer HealthLog cannot read.",
notFound: "The document archive has no such document or organization.",
+4 -1
View File
@@ -43,8 +43,10 @@ export const DOCUMENT_PICKER_MAX_TAGS = 1000;
/** Paperless-ngx document ids are integers. */
export const PAPERLESS_ID = /^\d{1,18}$/;
/** Papra document and organization ids: letters, digits, `_` and `-`. */
/** Papra document ids: letters, digits, `_` and `-`. */
export const PAPRA_ID = /^[A-Za-z0-9_-]{1,128}$/;
/** Papra organization ids, as Papra issues them. */
export const PAPRA_ORG_ID = /^org_[a-z0-9]{24}$/;
/**
* Is `id` a document id `system` could have issued? Checked before an id is
@@ -134,6 +136,7 @@ export const DOCUMENT_SOURCE_ERROR_CODES = [
"documents.sources.unreachable",
"documents.sources.redirected",
"documents.sources.authRefused",
"documents.sources.permissionMissing",
"documents.sources.versionTooOld",
"documents.sources.badResponse",
"documents.sources.notFound",
+1
View File
@@ -145,6 +145,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"documents.sources.notFound",
"documents.sources.organizationRequired",
"documents.sources.originNotAllowed",
"documents.sources.permissionMissing",
"documents.sources.rateLimited",
"documents.sources.redirected",
"documents.sources.tokenRequired",
+2 -2
View File
@@ -95,13 +95,13 @@ const refusal = {
const unavailable = {
description:
"`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization.",
"`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including one the key's user is not a member of (Papra 403 `user.not_in_organization`) and one Papra cannot parse (400).",
content: { "application/json": { schema: errorEnvelope } },
};
const upstream = {
description:
"The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source answered with a redirect, which is never followed), `documents.sources.authRefused` (401/403 from the source), `documents.sources.versionTooOld` (Paperless-ngx older than 2.16 / API version 9), `documents.sources.badResponse` (not the documented JSON, or larger than 1 MiB).",
"The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source answered with a redirect, which is never followed), `documents.sources.authRefused` (401/403 from the source), `documents.sources.permissionMissing` (Papra: the key cannot read tags; needs `tags:read`), `documents.sources.versionTooOld` (Paperless-ngx older than 2.16 / API version 9), `documents.sources.badResponse` (not the documented JSON, or larger than 1 MiB).",
content: { "application/json": { schema: errorEnvelope } },
};
+2 -2
View File
@@ -10,7 +10,7 @@ import {
DOCUMENT_PICKER_LINK_KINDS,
DOCUMENT_PICKER_MAX_PAGE,
DOCUMENT_PICKER_QUERY_MAX,
PAPRA_ID,
PAPRA_ORG_ID,
} from "@/lib/documents/sources/types";
import {
documentSourceIdSchema,
@@ -38,7 +38,7 @@ export const documentSourceSaveSchema = z.object({
organizationId: z
.string()
.trim()
.regex(PAPRA_ID, "Expected letters, digits, _ or -")
.regex(PAPRA_ORG_ID, "Expected a Papra organization id (org_…)")
.optional(),
token: z
.string()