diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5be68b466..22c56b089 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 — diff --git a/messages/de.json b/messages/de.json index b0426eec2..3dead10f6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index f42ec1e19..2e3c9feb5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 6f9e5127b..5447d74ba 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index 93f029e4c..e75cca526 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 23f268c99..802991bbc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/ko.json b/messages/ko.json index fef692073..ace3dd8ac 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "다시 검색하거나 필터를 바꿔 문서를 더 고르세요.", diff --git a/messages/pl.json b/messages/pl.json index 7cb49c5ae..fef668a57 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/src/components/documents/sources/source-errors.ts b/src/components/documents/sources/source-errors.ts index 3c1739924..9ec63e293 100644 --- a/src/components/documents/sources/source-errors.ts +++ b/src/components/documents/sources/source-errors.ts @@ -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": diff --git a/src/lib/documents/sources/__tests__/clients.test.ts b/src/lib/documents/sources/__tests__/clients.test.ts index 78f4f536b..ed6291335 100644 --- a/src/lib/documents/sources/__tests__/clients.test.ts +++ b/src/lib/documents/sources/__tests__/clients.test.ts @@ -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", diff --git a/src/lib/documents/sources/clients.ts b/src/lib/documents/sources/clients.ts index a5d1cfa85..33d9371bf 100644 --- a/src/lib/documents/sources/clients.ts +++ b/src/lib/documents/sources/clients.ts @@ -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), diff --git a/src/lib/documents/sources/http.ts b/src/lib/documents/sources/http.ts index 473d442ea..d5daf1b25 100644 --- a/src/lib/documents/sources/http.ts +++ b/src/lib/documents/sources/http.ts @@ -62,6 +62,8 @@ export interface SourceTarget { /** Origin plus the optional base path, no trailing slash. */ baseUrl: string; headers: Record; + /** 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 { } } +/** + * 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 { + 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; + const nested = record.error as Record | 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 { - 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); diff --git a/src/lib/documents/sources/route-support.ts b/src/lib/documents/sources/route-support.ts index a1b9aba86..126144674 100644 --- a/src/lib/documents/sources/route-support.ts +++ b/src/lib/documents/sources/route-support.ts @@ -83,6 +83,7 @@ const STATUS: Record = { unreachable: 502, redirected: 502, authRefused: 502, + permissionMissing: 502, versionTooOld: 502, badResponse: 502, notFound: 404, @@ -99,6 +100,8 @@ const MESSAGE: Record = { 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.", diff --git a/src/lib/documents/sources/types.ts b/src/lib/documents/sources/types.ts index 6dd8ddf35..3fdbfd1aa 100644 --- a/src/lib/documents/sources/types.ts +++ b/src/lib/documents/sources/types.ts @@ -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", diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index f8bbf0e7b..bb300d4ae 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -145,6 +145,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "documents.sources.notFound", "documents.sources.organizationRequired", "documents.sources.originNotAllowed", + "documents.sources.permissionMissing", "documents.sources.rateLimited", "documents.sources.redirected", "documents.sources.tokenRequired", diff --git a/src/lib/openapi/routes/document-sources.ts b/src/lib/openapi/routes/document-sources.ts index 53f4cfe6f..0b97d283c 100644 --- a/src/lib/openapi/routes/document-sources.ts +++ b/src/lib/openapi/routes/document-sources.ts @@ -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 } }, }; diff --git a/src/lib/validations/document-sources.ts b/src/lib/validations/document-sources.ts index d175c601c..4034d4c39 100644 --- a/src/lib/validations/document-sources.ts +++ b/src/lib/validations/document-sources.ts @@ -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()