mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } },
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user