mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
+17
-13
@@ -21065,7 +21065,7 @@ paths:
|
||||
maxLength: 2048
|
||||
organizationId:
|
||||
type: string
|
||||
pattern: ^[A-Za-z0-9_-]{1,128}$
|
||||
pattern: ^org_[a-z0-9]{24}$
|
||||
token:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -21085,7 +21085,9 @@ paths:
|
||||
"404": &a55
|
||||
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.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:
|
||||
@@ -21104,7 +21106,8 @@ paths:
|
||||
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),
|
||||
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:
|
||||
@@ -21327,9 +21330,10 @@ paths:
|
||||
"404":
|
||||
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.linkTargetNotFound`: the record in `link` is not the caller's own live conditionEpisode /
|
||||
encounter / vaccination."
|
||||
`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). `documents.sources.linkTargetNotFound`: the record in `link` is not the caller's own live
|
||||
conditionEpisode / encounter / vaccination."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -31239,13 +31243,13 @@ components:
|
||||
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
|
||||
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
|
||||
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired,
|
||||
documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter
|
||||
— encounter.invalid, encounter.link.invalid, encounter.practitioner-not-found,
|
||||
encounter.reminder-conflict, encounter.reminder-not-found; environment — environment.invalid,
|
||||
environment.no_home, environment.range_too_large, environment.travel.not_found; export —
|
||||
export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback —
|
||||
feedback.body.invalid; health_score_config — health_score_config.invalid,
|
||||
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
|
||||
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
|
||||
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
|
||||
encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment
|
||||
— environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found;
|
||||
export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid;
|
||||
feedback — feedback.body.invalid; health_score_config — health_score_config.invalid,
|
||||
health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled,
|
||||
illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window,
|
||||
illness.episode.parent-not-found, illness.insights.invalid; import — import.write_failed; ingest —
|
||||
|
||||
+4
-3
@@ -6711,7 +6711,7 @@
|
||||
"baseUrl": "Adresse",
|
||||
"allowedOrigins": "Auf diesem Server erlaubt: {origins}",
|
||||
"organizationId": "Organisations-ID",
|
||||
"organizationHelp": "Der Teil nach /organizations/ in der Adresszeile von Papra.",
|
||||
"organizationHelp": "Der Teil nach /organizations/ in der Adresszeile von Papra. Papra-Organisations-IDs sehen aus wie org_ gefolgt von 24 Buchstaben und Ziffern.",
|
||||
"token": "API-Token",
|
||||
"tokenSaved": "Für diese Adresse gespeichert. Leer lassen, um es zu behalten.",
|
||||
"tokenHelpPaperless": "In Paperless-ngx: Mein Profil, API-Token.",
|
||||
@@ -10220,7 +10220,7 @@
|
||||
"authRefused": "Das Archiv hat das Token abgelehnt. Prüfe das Token und seine Berechtigungen.",
|
||||
"versionTooOld": "Das Archiv ist zu alt. Nötig ist Paperless-ngx 2.16 oder neuer.",
|
||||
"badResponse": "Die Antwort des Archivs ließ sich nicht lesen. Prüfe, ob die Adresse auf das Archiv selbst zeigt.",
|
||||
"notFound": "Das Archiv kennt dieses Dokument oder diese Organisation nicht.",
|
||||
"notFound": "Das Archiv kennt dieses Dokument nicht, oder die Organisations-ID ist falsch oder nicht deine.",
|
||||
"linkTargetNotFound": "Der Eintrag, mit dem verknüpft werden sollte, existiert nicht mehr.",
|
||||
"rateLimited": "Zu viele Anfragen. Warte eine Minute und versuche es erneut.",
|
||||
"browserOnly": "Dokumentarchive lassen sich nur im Browser verwenden.",
|
||||
@@ -10233,7 +10233,8 @@
|
||||
"uploadLimit": "Das stündliche Upload-Limit ist erreicht. Versuche es später erneut.",
|
||||
"uploadBusy": "Andere Uploads laufen noch. Versuche es gleich noch einmal.",
|
||||
"moduleDisabled": "Dokumente sind in den Einstellungen ausgeschaltet.",
|
||||
"generic": "Etwas ist schiefgelaufen. Versuche es erneut."
|
||||
"generic": "Etwas ist schiefgelaufen. Versuche es erneut.",
|
||||
"permissionMissing": "Der Papra-Schlüssel darf keine Tags lesen. Gib ihm die Berechtigung tags:read."
|
||||
},
|
||||
"pickMore": "Weitere auswählen",
|
||||
"finishedHint": "Suche erneut oder ändere einen Filter, um weitere Dokumente auszuwählen.",
|
||||
|
||||
+4
-3
@@ -6711,7 +6711,7 @@
|
||||
"baseUrl": "Address",
|
||||
"allowedOrigins": "Allowed on this server: {origins}",
|
||||
"organizationId": "Organization id",
|
||||
"organizationHelp": "The part after /organizations/ in the Papra address bar.",
|
||||
"organizationHelp": "The part after /organizations/ in the Papra address bar. Papra organization ids look like org_ followed by 24 letters and digits.",
|
||||
"token": "API token",
|
||||
"tokenSaved": "Saved for this address. Leave empty to keep it.",
|
||||
"tokenHelpPaperless": "In Paperless-ngx: My Profile, API token.",
|
||||
@@ -10220,7 +10220,7 @@
|
||||
"authRefused": "The archive refused the token. Check the token and its permissions.",
|
||||
"versionTooOld": "The archive is too old. Paperless-ngx 2.16 or later is needed.",
|
||||
"badResponse": "The archive's answer could not be read. Check that the address points at the archive itself.",
|
||||
"notFound": "The archive has no such document, or no such organization.",
|
||||
"notFound": "The archive has no such document, or the organization id is wrong or not yours.",
|
||||
"linkTargetNotFound": "The record to link to no longer exists.",
|
||||
"rateLimited": "Too many requests. Wait a minute and try again.",
|
||||
"browserOnly": "Document archives can only be used in the browser.",
|
||||
@@ -10233,7 +10233,8 @@
|
||||
"uploadLimit": "The hourly upload limit is reached. Try again later.",
|
||||
"uploadBusy": "Other uploads are still running. Try again in a moment.",
|
||||
"moduleDisabled": "Documents are switched off in Settings.",
|
||||
"generic": "Something went wrong. Try again."
|
||||
"generic": "Something went wrong. Try again.",
|
||||
"permissionMissing": "The Papra key cannot read tags. Give it the tags:read permission."
|
||||
},
|
||||
"pickMore": "Pick more",
|
||||
"finishedHint": "Search again or change a filter to pick more documents.",
|
||||
|
||||
+4
-3
@@ -6711,7 +6711,7 @@
|
||||
"baseUrl": "Dirección",
|
||||
"allowedOrigins": "Permitido en este servidor: {origins}",
|
||||
"organizationId": "Id de organización",
|
||||
"organizationHelp": "La parte que sigue a /organizations/ en la barra de direcciones de Papra.",
|
||||
"organizationHelp": "La parte que sigue a /organizations/ en la barra de direcciones de Papra. Los id de organización de Papra tienen la forma org_ seguida de 24 letras y cifras.",
|
||||
"token": "Token de la API",
|
||||
"tokenSaved": "Guardado para esta dirección. Déjalo vacío para conservarlo.",
|
||||
"tokenHelpPaperless": "En Paperless-ngx: Mi perfil, token de la API.",
|
||||
@@ -10220,7 +10220,7 @@
|
||||
"authRefused": "El archivo rechazó el token. Comprueba el token y sus permisos.",
|
||||
"versionTooOld": "El archivo es demasiado antiguo. Se necesita Paperless-ngx 2.16 o posterior.",
|
||||
"badResponse": "No se pudo leer la respuesta del archivo. Comprueba que la dirección apunta al propio archivo.",
|
||||
"notFound": "El archivo no tiene ese documento o esa organización.",
|
||||
"notFound": "El archivo no tiene ese documento, o el id de organización es incorrecto o no es tuyo.",
|
||||
"linkTargetNotFound": "El registro que se iba a vincular ya no existe.",
|
||||
"rateLimited": "Demasiadas solicitudes. Espera un minuto y vuelve a intentarlo.",
|
||||
"browserOnly": "Los archivos de documentos solo se pueden usar en el navegador.",
|
||||
@@ -10233,7 +10233,8 @@
|
||||
"uploadLimit": "Se alcanzó el límite de subidas por hora. Inténtalo más tarde.",
|
||||
"uploadBusy": "Aún hay otras subidas en curso. Inténtalo en un momento.",
|
||||
"moduleDisabled": "Los documentos están desactivados en Ajustes.",
|
||||
"generic": "Algo salió mal. Inténtalo de nuevo."
|
||||
"generic": "Algo salió mal. Inténtalo de nuevo.",
|
||||
"permissionMissing": "La clave de Papra no puede leer etiquetas. Dale el permiso tags:read."
|
||||
},
|
||||
"pickMore": "Elegir más",
|
||||
"finishedHint": "Busca de nuevo o cambia un filtro para elegir más documentos.",
|
||||
|
||||
+4
-3
@@ -6711,7 +6711,7 @@
|
||||
"baseUrl": "Adresse",
|
||||
"allowedOrigins": "Autorisé sur ce serveur : {origins}",
|
||||
"organizationId": "Identifiant d'organisation",
|
||||
"organizationHelp": "La partie après /organizations/ dans la barre d'adresse de Papra.",
|
||||
"organizationHelp": "La partie après /organizations/ dans la barre d'adresse de Papra. Les identifiants d'organisation Papra ont la forme org_ suivi de 24 lettres et chiffres.",
|
||||
"token": "Jeton d'API",
|
||||
"tokenSaved": "Enregistré pour cette adresse. Laissez vide pour le conserver.",
|
||||
"tokenHelpPaperless": "Dans Paperless-ngx : Mon profil, jeton d'API.",
|
||||
@@ -10220,7 +10220,7 @@
|
||||
"authRefused": "L'archive a refusé le jeton. Vérifiez le jeton et ses droits.",
|
||||
"versionTooOld": "L'archive est trop ancienne. Paperless-ngx 2.16 ou plus récent est nécessaire.",
|
||||
"badResponse": "La réponse de l'archive est illisible. Vérifiez que l'adresse pointe vers l'archive elle-même.",
|
||||
"notFound": "L'archive ne connaît pas ce document ou cette organisation.",
|
||||
"notFound": "L'archive ne connaît pas ce document, ou l'identifiant d'organisation est faux ou n'est pas le vôtre.",
|
||||
"linkTargetNotFound": "L'élément auquel lier le document n'existe plus.",
|
||||
"rateLimited": "Trop de requêtes. Patientez une minute et réessayez.",
|
||||
"browserOnly": "Les archives de documents ne s'utilisent que dans le navigateur.",
|
||||
@@ -10233,7 +10233,8 @@
|
||||
"uploadLimit": "La limite horaire d'envois est atteinte. Réessayez plus tard.",
|
||||
"uploadBusy": "D'autres envois sont en cours. Réessayez dans un instant.",
|
||||
"moduleDisabled": "Les documents sont désactivés dans les réglages.",
|
||||
"generic": "Un problème est survenu. Réessayez."
|
||||
"generic": "Un problème est survenu. Réessayez.",
|
||||
"permissionMissing": "La clé Papra ne peut pas lire les étiquettes. Donnez-lui le droit tags:read."
|
||||
},
|
||||
"pickMore": "En choisir d'autres",
|
||||
"finishedHint": "Cherchez à nouveau ou changez un filtre pour choisir d'autres documents.",
|
||||
|
||||
+4
-3
@@ -6711,7 +6711,7 @@
|
||||
"baseUrl": "Indirizzo",
|
||||
"allowedOrigins": "Consentito su questo server: {origins}",
|
||||
"organizationId": "Id organizzazione",
|
||||
"organizationHelp": "La parte dopo /organizations/ nella barra degli indirizzi di Papra.",
|
||||
"organizationHelp": "La parte dopo /organizations/ nella barra degli indirizzi di Papra. Gli id organizzazione di Papra hanno la forma org_ seguita da 24 lettere e cifre.",
|
||||
"token": "Token API",
|
||||
"tokenSaved": "Salvato per questo indirizzo. Lascia vuoto per mantenerlo.",
|
||||
"tokenHelpPaperless": "In Paperless-ngx: Il mio profilo, token API.",
|
||||
@@ -10220,7 +10220,7 @@
|
||||
"authRefused": "L'archivio ha rifiutato il token. Controlla il token e i suoi permessi.",
|
||||
"versionTooOld": "L'archivio è troppo vecchio. Serve Paperless-ngx 2.16 o successivo.",
|
||||
"badResponse": "Non è stato possibile leggere la risposta dell'archivio. Verifica che l'indirizzo punti all'archivio stesso.",
|
||||
"notFound": "L'archivio non ha questo documento o questa organizzazione.",
|
||||
"notFound": "L'archivio non ha questo documento, oppure l'id dell'organizzazione è sbagliato o non è tuo.",
|
||||
"linkTargetNotFound": "L'elemento da collegare non esiste più.",
|
||||
"rateLimited": "Troppe richieste. Attendi un minuto e riprova.",
|
||||
"browserOnly": "Gli archivi di documenti si usano solo nel browser.",
|
||||
@@ -10233,7 +10233,8 @@
|
||||
"uploadLimit": "È stato raggiunto il limite orario di caricamenti. Riprova più tardi.",
|
||||
"uploadBusy": "Altri caricamenti sono ancora in corso. Riprova tra un momento.",
|
||||
"moduleDisabled": "I documenti sono disattivati nelle impostazioni.",
|
||||
"generic": "Qualcosa è andato storto. Riprova."
|
||||
"generic": "Qualcosa è andato storto. Riprova.",
|
||||
"permissionMissing": "La chiave Papra non può leggere le etichette. Dalle il permesso tags:read."
|
||||
},
|
||||
"pickMore": "Scegline altri",
|
||||
"finishedHint": "Cerca di nuovo o cambia un filtro per scegliere altri documenti.",
|
||||
|
||||
+4
-3
@@ -6711,7 +6711,7 @@
|
||||
"baseUrl": "주소",
|
||||
"allowedOrigins": "이 서버에서 허용됨: {origins}",
|
||||
"organizationId": "조직 ID",
|
||||
"organizationHelp": "Papra 주소 표시줄에서 /organizations/ 다음 부분입니다.",
|
||||
"organizationHelp": "Papra 주소 표시줄에서 /organizations/ 다음 부분입니다. Papra 조직 ID는 org_ 뒤에 영문자와 숫자 24자가 오는 형식입니다.",
|
||||
"token": "API 토큰",
|
||||
"tokenSaved": "이 주소에 저장됨. 유지하려면 비워 두세요.",
|
||||
"tokenHelpPaperless": "Paperless-ngx: 내 프로필의 API 토큰.",
|
||||
@@ -10220,7 +10220,7 @@
|
||||
"authRefused": "보관함이 토큰을 거부했습니다. 토큰과 권한을 확인하세요.",
|
||||
"versionTooOld": "보관함 버전이 너무 오래되었습니다. Paperless-ngx 2.16 이상이 필요합니다.",
|
||||
"badResponse": "보관함의 응답을 읽을 수 없습니다. 주소가 보관함 자체를 가리키는지 확인하세요.",
|
||||
"notFound": "보관함에 해당 문서나 조직이 없습니다.",
|
||||
"notFound": "보관함에 해당 문서가 없거나, 조직 ID가 잘못되었거나 본인의 조직이 아닙니다.",
|
||||
"linkTargetNotFound": "연결할 기록이 더 이상 존재하지 않습니다.",
|
||||
"rateLimited": "요청이 너무 많습니다. 1분 후에 다시 시도하세요.",
|
||||
"browserOnly": "문서 보관함은 브라우저에서만 사용할 수 있습니다.",
|
||||
@@ -10233,7 +10233,8 @@
|
||||
"uploadLimit": "시간당 업로드 한도에 도달했습니다. 나중에 다시 시도하세요.",
|
||||
"uploadBusy": "다른 업로드가 아직 진행 중입니다. 잠시 후 다시 시도하세요.",
|
||||
"moduleDisabled": "설정에서 문서 기능이 꺼져 있습니다.",
|
||||
"generic": "문제가 발생했습니다. 다시 시도하세요."
|
||||
"generic": "문제가 발생했습니다. 다시 시도하세요.",
|
||||
"permissionMissing": "Papra 키가 태그를 읽을 수 없습니다. tags:read 권한을 부여하세요."
|
||||
},
|
||||
"pickMore": "더 고르기",
|
||||
"finishedHint": "다시 검색하거나 필터를 바꿔 문서를 더 고르세요.",
|
||||
|
||||
+4
-3
@@ -6711,7 +6711,7 @@
|
||||
"baseUrl": "Adres",
|
||||
"allowedOrigins": "Dozwolone na tym serwerze: {origins}",
|
||||
"organizationId": "Identyfikator organizacji",
|
||||
"organizationHelp": "Część po /organizations/ w pasku adresu Papra.",
|
||||
"organizationHelp": "Część po /organizations/ w pasku adresu Papra. Identyfikatory organizacji Papra mają postać org_ i 24 litery oraz cyfry.",
|
||||
"token": "Token API",
|
||||
"tokenSaved": "Zapisano dla tego adresu. Zostaw puste, aby go zachować.",
|
||||
"tokenHelpPaperless": "W Paperless-ngx: Mój profil, token API.",
|
||||
@@ -10220,7 +10220,7 @@
|
||||
"authRefused": "Archiwum odrzuciło token. Sprawdź token i jego uprawnienia.",
|
||||
"versionTooOld": "Archiwum jest za stare. Potrzebny jest Paperless-ngx 2.16 lub nowszy.",
|
||||
"badResponse": "Nie udało się odczytać odpowiedzi archiwum. Sprawdź, czy adres wskazuje na samo archiwum.",
|
||||
"notFound": "Archiwum nie ma takiego dokumentu ani takiej organizacji.",
|
||||
"notFound": "Archiwum nie ma takiego dokumentu albo identyfikator organizacji jest błędny lub nie należy do ciebie.",
|
||||
"linkTargetNotFound": "Wpis, z którym miało nastąpić powiązanie, już nie istnieje.",
|
||||
"rateLimited": "Za dużo żądań. Odczekaj minutę i spróbuj ponownie.",
|
||||
"browserOnly": "Z archiwów dokumentów można korzystać tylko w przeglądarce.",
|
||||
@@ -10233,7 +10233,8 @@
|
||||
"uploadLimit": "Osiągnięto godzinny limit przesyłania. Spróbuj później.",
|
||||
"uploadBusy": "Trwają jeszcze inne przesyłania. Spróbuj za chwilę.",
|
||||
"moduleDisabled": "Dokumenty są wyłączone w ustawieniach.",
|
||||
"generic": "Coś poszło nie tak. Spróbuj ponownie."
|
||||
"generic": "Coś poszło nie tak. Spróbuj ponownie.",
|
||||
"permissionMissing": "Klucz Papra nie może odczytać tagów. Nadaj mu uprawnienie tags:read."
|
||||
},
|
||||
"pickMore": "Wybierz więcej",
|
||||
"finishedHint": "Wyszukaj ponownie lub zmień filtr, aby wybrać więcej dokumentów.",
|
||||
|
||||
@@ -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