Compare commits

..

1 Commits

Author SHA1 Message Date
Corentin Thomasset
9ed9f34ee8 wip 2025-09-08 23:19:16 +02:00
160 changed files with 426 additions and 1716 deletions

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Lazy load the PDF viewer to reduce the main chunk size

View File

@@ -0,0 +1,6 @@
---
"@papra/app-client": patch
"@papra/app-server": patch
---
Allow for more complex intake-email origin adresses

View File

@@ -0,0 +1,8 @@
---
"@papra/webhooks": minor
"@papra/api-sdk": minor
"@papra/lecture": minor
"@papra/cli": minor
---
Ditched CommonJs build for packages

View File

@@ -0,0 +1,5 @@
---
"@papra/app-server": patch
---
Use node file streams in ingestion folder for smaller RAM footprint

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Simplified i18n tooling + improved performances

View File

@@ -0,0 +1,5 @@
---
"@papra/app-server": patch
---
Fixed an issue where tags assigned to only deleted documents won't show up in the tag list

View File

@@ -0,0 +1,5 @@
---
"@papra/app-server": minor
---
Dropped support for the dedicated backblaze b2 storage driver as b2 now fully support s3 client

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Prevent infinit loading in search modal when an error occure

View File

@@ -0,0 +1,6 @@
---
"@papra/app-server": minor
"@papra/docs": minor
---
Added documents encryption layer

View File

@@ -0,0 +1,5 @@
---
"@papra/app-server": patch
---
Properly handle missing files errors in storage drivers

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Improved the UX of the document content edition panel

View File

@@ -0,0 +1,5 @@
---
"@papra/app-server": minor
---
Stream file upload instead of full in-memory loading

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Added content edition support in demo mode

2
.gitignore vendored
View File

@@ -35,8 +35,6 @@ cache
*.db-shm *.db-shm
*.db-wal *.db-wal
*.sqlite *.sqlite
*.sqlite-shm
*.sqlite-wal
local-documents local-documents
ingestion ingestion

View File

@@ -105,73 +105,6 @@ We recommend running the app locally for development. Follow these steps:
6. Open your browser and navigate to `http://localhost:3000`. 6. Open your browser and navigate to `http://localhost:3000`.
### IDE Setup
#### ESLint Extension
We recommend installing the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) for VS Code to get real-time linting feedback and automatic code fixing.
The linting configuration is based on [@antfu/eslint-config](https://github.com/antfu/eslint-config), you can find specific IDE configurations in their repository.
<details>
<summary>Recommended VS Code Settings</summary>
Create or update your `.vscode/settings.json` file with the following configuration:
```json
{
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in your IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"svelte",
"css",
"less",
"scss",
"pcss",
"postcss"
]
}
```
</details>
### Testing ### Testing
We use **Vitest** for testing. Each package comes with its own testing commands. We use **Vitest** for testing. Each package comes with its own testing commands.

View File

@@ -1,17 +1,5 @@
# @papra/docs # @papra/docs
## 0.6.1
### Patch Changes
- [#512](https://github.com/papra-hq/papra/pull/512) [`cb3ce6b`](https://github.com/papra-hq/papra/commit/cb3ce6b1d8d5dba09cbf0d2964f14b1c93220571) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added organizations permissions for api keys
## 0.6.0
### Minor Changes
- [#480](https://github.com/papra-hq/papra/pull/480) [`0a03f42`](https://github.com/papra-hq/papra/commit/0a03f42231f691d339c7ab5a5916c52385e31bd2) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added documents encryption layer
## 0.5.3 ## 0.5.3
### Patch Changes ### Patch Changes

View File

@@ -1,7 +1,7 @@
{ {
"name": "@papra/docs", "name": "@papra/docs",
"type": "module", "type": "module",
"version": "0.6.1", "version": "0.5.3",
"private": true, "private": true,
"packageManager": "pnpm@10.12.3", "packageManager": "pnpm@10.12.3",
"description": "Papra documentation website", "description": "Papra documentation website",
@@ -37,7 +37,7 @@
"@unocss/reset": "^0.64.0", "@unocss/reset": "^0.64.0",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-plugin-astro": "^1.3.1", "eslint-plugin-astro": "^1.3.1",
"figue": "^3.1.1", "figue": "^2.2.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"marked": "^15.0.6", "marked": "^15.0.6",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@@ -1,5 +1,5 @@
import type { ConfigDefinition, ConfigDefinitionElement } from 'figue'; import type { ConfigDefinition, ConfigDefinitionElement } from 'figue';
import { castArray, isArray, isEmpty, isNil } from 'lodash-es'; import { isArray, isEmpty, isNil } from 'lodash-es';
import { marked } from 'marked'; import { marked } from 'marked';
import { configDefinition } from '../../papra-server/src/modules/config/config'; import { configDefinition } from '../../papra-server/src/modules/config/config';
@@ -46,21 +46,16 @@ const rows = configDetails
}; };
}); });
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => { const mdSections = rows.map(({ documentation, env, path, defaultValue }) => `
const envs = castArray(env); ### ${env}
const [firstEnv, ...restEnvs] = envs;
return `
### ${firstEnv}
${documentation} ${documentation}
- Path: \`${path.join('.')}\` - Path: \`${path.join('.')}\`
- Environment variable: \`${firstEnv}\` ${restEnvs.length ? `, with fallback to: ${restEnvs.map(e => `\`${e}\``).join(', ')}` : ''} - Environment variable: \`${env}\`
- Default value: \`${defaultValue}\` - Default value: \`${defaultValue}\`
`.trim(); `.trim()).join('\n\n---\n\n');
}).join('\n\n---\n\n');
function wrapText(text: string, maxLength = 75) { function wrapText(text: string, maxLength = 75) {
const words = text.split(' '); const words = text.split(' ');
@@ -85,12 +80,10 @@ function wrapText(text: string, maxLength = 75) {
const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => { const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => {
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === ''; const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
const envs = castArray(env);
const [firstEnv] = envs;
return [ return [
...wrapText(documentation), ...wrapText(documentation),
`# ${firstEnv}=${isEmptyDefaultValue ? '' : defaultValue}`, `# ${env}=${isEmptyDefaultValue ? '' : defaultValue}`,
].join('\n'); ].join('\n');
}).join('\n\n'); }).join('\n\n');

View File

@@ -18,107 +18,8 @@ The public API uses a bearer token for authentication. You can get a token by lo
</details> </details>
To authenticate your requests, include the token in the `Authorization` header with the `Bearer` prefix:
```
Authorization: Bearer YOUR_API_TOKEN
```
### Examples
**Using cURL:**
```bash
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
https://api.papra.app/api/organizations
```
**Using JavaScript (fetch):**
```javascript
const response = await fetch('https://api.papra.app/api/organizations', {
headers: {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Content-Type': 'application/json'
}
})
```
### API Key Permissions
When creating an API key, you can select from the following permissions:
**Organizations:**
- `organizations:create` - Create new organizations
- `organizations:read` - Read organization information and list organizations of the user
- `organizations:update` - Update organization details
- `organizations:delete` - Delete organizations
**Documents:**
- `documents:create` - Upload and create new documents
- `documents:read` - Read and download documents
- `documents:update` - Update document metadata and content
- `documents:delete` - Delete documents
**Tags:**
- `tags:create` - Create new tags
- `tags:read` - Read tag information and list tags
- `tags:update` - Update tag details
- `tags:delete` - Delete tags
## Endpoints ## Endpoints
### List organizations
**GET** `/api/organizations`
List all organizations accessible to the authenticated user.
- Required API key permissions: `organizations:read`
- Response (JSON)
- `organizations`: The list of organizations.
### Create an organization
**POST** `/api/organizations`
Create a new organization.
- Required API key permissions: `organizations:create`
- Body (JSON)
- `name`: The organization name (3-50 characters).
- Response (JSON)
- `organization`: The created organization.
### Get an organization
**GET** `/api/organizations/:organizationId`
Get an organization by its ID.
- Required API key permissions: `organizations:read`
- Response (JSON)
- `organization`: The organization.
### Update an organization
**PUT** `/api/organizations/:organizationId`
Update an organization's name.
- Required API key permissions: `organizations:update`
- Body (JSON)
- `name`: The new organization name (3-50 characters).
- Response (JSON)
- `organization`: The updated organization.
### Delete an organization
**DELETE** `/api/organizations/:organizationId`
Delete an organization by its ID.
- Required API key permissions: `organizations:delete`
- Response: empty (204 status code)
### Create a document ### Create a document
**POST** `/api/organizations/:organizationId/documents` **POST** `/api/organizations/:organizationId/documents`

View File

@@ -1,59 +1,5 @@
# @papra/app-client # @papra/app-client
## 0.9.5
### Patch Changes
- [#517](https://github.com/papra-hq/papra/pull/517) [`a3f9f05`](https://github.com/papra-hq/papra/commit/a3f9f05c664b4995b62db59f2e9eda8a3bfef0de) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Prevented organization deletion by non-organization owner
## 0.9.4
### Patch Changes
- [#512](https://github.com/papra-hq/papra/pull/512) [`cb3ce6b`](https://github.com/papra-hq/papra/commit/cb3ce6b1d8d5dba09cbf0d2964f14b1c93220571) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added organizations permissions for api keys
## 0.9.3
### Patch Changes
- [#506](https://github.com/papra-hq/papra/pull/506) [`6bcb2a7`](https://github.com/papra-hq/papra/commit/6bcb2a71e990d534dd12d84e64a38f2b2baea25a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to define patterns for email intake username generation
## 0.9.2
### Patch Changes
- [#501](https://github.com/papra-hq/papra/pull/501) [`b5bf0cc`](https://github.com/papra-hq/papra/commit/b5bf0cca4b571495329cb553da06e0d334ee8968) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix an issue preventing to disable the max upload size
- [#498](https://github.com/papra-hq/papra/pull/498) [`3da13f7`](https://github.com/papra-hq/papra/commit/3da13f759155df5d7c532160a7ea582385db63b6) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Removed the "open in new tab" button for security improvement (xss prevention)
## 0.9.1
### Patch Changes
- [#492](https://github.com/papra-hq/papra/pull/492) [`54514e1`](https://github.com/papra-hq/papra/commit/54514e15db5deaffc59dcba34929b5e2e74282e1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a client side guard for rejecting too-big files
- [#488](https://github.com/papra-hq/papra/pull/488) [`83e943c`](https://github.com/papra-hq/papra/commit/83e943c5b46432e55b6dfbaa587019a95ffab466) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix favicons display issues on firefox
- [#492](https://github.com/papra-hq/papra/pull/492) [`54514e1`](https://github.com/papra-hq/papra/commit/54514e15db5deaffc59dcba34929b5e2e74282e1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix i18n messages when a file-too-big error happens
- [#492](https://github.com/papra-hq/papra/pull/492) [`54514e1`](https://github.com/papra-hq/papra/commit/54514e15db5deaffc59dcba34929b5e2e74282e1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Clean all upload method to happen through the import status modal
## 0.9.0
### Patch Changes
- [#471](https://github.com/papra-hq/papra/pull/471) [`e77a42f`](https://github.com/papra-hq/papra/commit/e77a42fbf14da011cd396426aa0bbea56c889740) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Lazy load the PDF viewer to reduce the main chunk size
- [#481](https://github.com/papra-hq/papra/pull/481) [`1606310`](https://github.com/papra-hq/papra/commit/1606310745e8edf405b527127078143481419e8c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Allow for more complex intake-email origin adresses
- [#470](https://github.com/papra-hq/papra/pull/470) [`d488efe`](https://github.com/papra-hq/papra/commit/d488efe2cc4aa4f433cec4e9b8cc909b091eccc4) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Simplified i18n tooling + improved performances
- [#468](https://github.com/papra-hq/papra/pull/468) [`14c3587`](https://github.com/papra-hq/papra/commit/14c3587de07a605ec586bdc428d9e76956bf1c67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Prevent infinit loading in search modal when an error occure
- [#468](https://github.com/papra-hq/papra/pull/468) [`14c3587`](https://github.com/papra-hq/papra/commit/14c3587de07a605ec586bdc428d9e76956bf1c67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improved the UX of the document content edition panel
- [#468](https://github.com/papra-hq/papra/pull/468) [`14c3587`](https://github.com/papra-hq/papra/commit/14c3587de07a605ec586bdc428d9e76956bf1c67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added content edition support in demo mode
## 0.8.2 ## 0.8.2
## 0.8.1 ## 0.8.1

View File

@@ -6,7 +6,8 @@ export default antfu({
}, },
ignores: [ ignores: [
'public/manifest.json', // Generated file
'src/modules/i18n/locales.types.ts',
], ],
rules: { rules: {

View File

@@ -27,23 +27,10 @@
<meta property="twitter:image" content="https://papra.app/og-image.png"> <meta property="twitter:image" content="https://papra.app/og-image.png">
<!-- Favicon and Icons --> <!-- Favicon and Icons -->
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png"> <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png"> <link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png"> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png"> <link rel="manifest" href="/site.webmanifest" />
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/manifest.json">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
<meta name="theme-color" content="#ffffff">
<!-- Structured Data (JSON-LD for rich snippets) --> <!-- Structured Data (JSON-LD for rich snippets) -->
<script type="application/ld+json"> <script type="application/ld+json">

View File

@@ -1,7 +1,7 @@
{ {
"name": "@papra/app-client", "name": "@papra/app-client",
"type": "module", "type": "module",
"version": "0.9.5", "version": "0.8.2",
"private": true, "private": true,
"packageManager": "pnpm@10.12.3", "packageManager": "pnpm@10.12.3",
"description": "Papra frontend client", "description": "Papra frontend client",
@@ -21,10 +21,12 @@
"serve": "vite preview", "serve": "vite preview",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint --fix .", "lint:fix": "eslint --fix .",
"test": "vitest run", "test": "pnpm check-i18n-types-outdated && vitest run",
"test:watch": "vitest watch", "test:watch": "vitest watch",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"check-i18n-types-outdated": "pnpm script:generate-i18n-types && git diff --exit-code -- src/modules/i18n/locales.types.ts > /dev/null || (echo \"Locales types are outdated, please run 'pnpm script:generate-i18n-types' and commit the changes.\" && exit 1)",
"script:get-missing-i18n-keys": "tsx src/scripts/get-missing-i18n-keys.script.ts",
"script:sync-i18n-key-order": "tsx src/scripts/sync-i18n-key-order.script.ts" "script:sync-i18n-key-order": "tsx src/scripts/sync-i18n-key-order.script.ts"
}, },
"dependencies": { "dependencies": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 831 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,41 +0,0 @@
{
"name": "Papra",
"icons": [
{
"src": "\/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "\/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "\/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "\/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "\/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "\/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -143,7 +143,6 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Organisation löschen', 'organization.settings.delete.confirm.confirm-button': 'Organisation löschen',
'organization.settings.delete.confirm.cancel-button': 'Abbrechen', 'organization.settings.delete.confirm.cancel-button': 'Abbrechen',
'organization.settings.delete.success': 'Organisation gelöscht', 'organization.settings.delete.success': 'Organisation gelöscht',
'organization.settings.delete.only-owner': 'Nur der Organisationsinhaber kann diese Organisation löschen.',
'organizations.members.title': 'Mitglieder', 'organizations.members.title': 'Mitglieder',
'organizations.members.description': 'Verwalten Sie Ihre Organisationsmitglieder', 'organizations.members.description': 'Verwalten Sie Ihre Organisationsmitglieder',
@@ -418,13 +417,6 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys // API keys
'api-keys.permissions.select-all': 'Alle auswählen',
'api-keys.permissions.deselect-all': 'Alle abwählen',
'api-keys.permissions.organizations.title': 'Organisationen',
'api-keys.permissions.organizations.organizations:create': 'Organisationen erstellen',
'api-keys.permissions.organizations.organizations:read': 'Organisationen lesen',
'api-keys.permissions.organizations.organizations:update': 'Organisationen aktualisieren',
'api-keys.permissions.organizations.organizations:delete': 'Organisationen löschen',
'api-keys.permissions.documents.title': 'Dokumente', 'api-keys.permissions.documents.title': 'Dokumente',
'api-keys.permissions.documents.documents:create': 'Dokumente erstellen', 'api-keys.permissions.documents.documents:create': 'Dokumente erstellen',
'api-keys.permissions.documents.documents:read': 'Dokumente lesen', 'api-keys.permissions.documents.documents:read': 'Dokumente lesen',
@@ -548,9 +540,8 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors // API errors
'api-errors.document.already_exists': 'Das Dokument existiert bereits', 'api-errors.document.already_exists': 'Das Dokument existiert bereits',
'api-errors.document.size_too_large': 'Die Datei ist zu groß', 'api-errors.document.file_too_big': 'Die Dokumentdatei ist zu groß',
'api-errors.intake-emails.already_exists': 'Eine Eingang-Email mit dieser Adresse existiert bereits.', 'api-errors.intake_email.limit_reached': 'Die maximale Anzahl an Eingangse-Mails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.',
'api-errors.intake_email.limit_reached': 'Die maximale Anzahl an Eingang-EMails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.',
'api-errors.user.max_organization_count_reached': 'Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Wenn Sie weitere erstellen möchten, kontaktieren Sie bitte den Support.', 'api-errors.user.max_organization_count_reached': 'Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Wenn Sie weitere erstellen möchten, kontaktieren Sie bitte den Support.',
'api-errors.default': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten.', 'api-errors.default': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten.',
'api-errors.organization.invitation_already_exists': 'Eine Einladung für diese E-Mail existiert bereits in dieser Organisation.', 'api-errors.organization.invitation_already_exists': 'Eine Einladung für diese E-Mail existiert bereits in dieser Organisation.',

View File

@@ -141,7 +141,6 @@ export const translations = {
'organization.settings.delete.confirm.confirm-button': 'Delete organization', 'organization.settings.delete.confirm.confirm-button': 'Delete organization',
'organization.settings.delete.confirm.cancel-button': 'Cancel', 'organization.settings.delete.confirm.cancel-button': 'Cancel',
'organization.settings.delete.success': 'Organization deleted', 'organization.settings.delete.success': 'Organization deleted',
'organization.settings.delete.only-owner': 'Only the organization owner can delete this organization.',
'organizations.members.title': 'Members', 'organizations.members.title': 'Members',
'organizations.members.description': 'Manage your organization members', 'organizations.members.description': 'Manage your organization members',
@@ -416,13 +415,6 @@ export const translations = {
// API keys // API keys
'api-keys.permissions.select-all': 'Select all',
'api-keys.permissions.deselect-all': 'Deselect all',
'api-keys.permissions.organizations.title': 'Organizations',
'api-keys.permissions.organizations.organizations:create': 'Create organizations',
'api-keys.permissions.organizations.organizations:read': 'Read organizations',
'api-keys.permissions.organizations.organizations:update': 'Update organizations',
'api-keys.permissions.organizations.organizations:delete': 'Delete organizations',
'api-keys.permissions.documents.title': 'Documents', 'api-keys.permissions.documents.title': 'Documents',
'api-keys.permissions.documents.documents:create': 'Create documents', 'api-keys.permissions.documents.documents:create': 'Create documents',
'api-keys.permissions.documents.documents:read': 'Read documents', 'api-keys.permissions.documents.documents:read': 'Read documents',
@@ -546,8 +538,7 @@ export const translations = {
// API errors // API errors
'api-errors.document.already_exists': 'The document already exists', 'api-errors.document.already_exists': 'The document already exists',
'api-errors.document.size_too_large': 'The file size is too large', 'api-errors.document.file_too_big': 'The document file is too big',
'api-errors.intake-emails.already_exists': 'An intake email with this address already exists.',
'api-errors.intake_email.limit_reached': 'The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.', 'api-errors.intake_email.limit_reached': 'The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.',
'api-errors.user.max_organization_count_reached': 'You have reached the maximum number of organizations you can create, if you need to create more, please contact support.', 'api-errors.user.max_organization_count_reached': 'You have reached the maximum number of organizations you can create, if you need to create more, please contact support.',
'api-errors.default': 'An error occurred while processing your request.', 'api-errors.default': 'An error occurred while processing your request.',

View File

@@ -143,7 +143,6 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Eliminar organización', 'organization.settings.delete.confirm.confirm-button': 'Eliminar organización',
'organization.settings.delete.confirm.cancel-button': 'Cancelar', 'organization.settings.delete.confirm.cancel-button': 'Cancelar',
'organization.settings.delete.success': 'Organización eliminada', 'organization.settings.delete.success': 'Organización eliminada',
'organization.settings.delete.only-owner': 'Solo el propietario de la organización puede eliminar esta organización.',
'organizations.members.title': 'Miembros', 'organizations.members.title': 'Miembros',
'organizations.members.description': 'Administra los miembros de tu organización', 'organizations.members.description': 'Administra los miembros de tu organización',
@@ -418,13 +417,6 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys // API keys
'api-keys.permissions.select-all': 'Seleccionar todo',
'api-keys.permissions.deselect-all': 'Deseleccionar todo',
'api-keys.permissions.organizations.title': 'Organizaciones',
'api-keys.permissions.organizations.organizations:create': 'Crear organizaciones',
'api-keys.permissions.organizations.organizations:read': 'Leer organizaciones',
'api-keys.permissions.organizations.organizations:update': 'Actualizar organizaciones',
'api-keys.permissions.organizations.organizations:delete': 'Eliminar organizaciones',
'api-keys.permissions.documents.title': 'Documentos', 'api-keys.permissions.documents.title': 'Documentos',
'api-keys.permissions.documents.documents:create': 'Crear documentos', 'api-keys.permissions.documents.documents:create': 'Crear documentos',
'api-keys.permissions.documents.documents:read': 'Leer documentos', 'api-keys.permissions.documents.documents:read': 'Leer documentos',
@@ -548,8 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors // API errors
'api-errors.document.already_exists': 'El documento ya existe', 'api-errors.document.already_exists': 'El documento ya existe',
'api-errors.document.size_too_large': 'El archivo es demasiado grande', 'api-errors.document.file_too_big': 'El archivo del documento es demasiado grande',
'api-errors.intake-emails.already_exists': 'Ya existe un correo de ingreso con esta dirección.',
'api-errors.intake_email.limit_reached': 'Se ha alcanzado el número máximo de correos de ingreso para esta organización. Por favor, mejora tu plan para crear más correos de ingreso.', 'api-errors.intake_email.limit_reached': 'Se ha alcanzado el número máximo de correos de ingreso para esta organización. Por favor, mejora tu plan para crear más correos de ingreso.',
'api-errors.user.max_organization_count_reached': 'Has alcanzado el número máximo de organizaciones que puedes crear, si necesitas crear más, contacta al soporte.', 'api-errors.user.max_organization_count_reached': 'Has alcanzado el número máximo de organizaciones que puedes crear, si necesitas crear más, contacta al soporte.',
'api-errors.default': 'Ocurrió un error al procesar tu solicitud.', 'api-errors.default': 'Ocurrió un error al procesar tu solicitud.',

View File

@@ -143,7 +143,6 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Supprimer l\'organisation', 'organization.settings.delete.confirm.confirm-button': 'Supprimer l\'organisation',
'organization.settings.delete.confirm.cancel-button': 'Annuler', 'organization.settings.delete.confirm.cancel-button': 'Annuler',
'organization.settings.delete.success': 'Organisation supprimée', 'organization.settings.delete.success': 'Organisation supprimée',
'organization.settings.delete.only-owner': 'Seul le propriétaire de l\'organisation peut supprimer cette organisation.',
'organizations.members.title': 'Membres', 'organizations.members.title': 'Membres',
'organizations.members.description': 'Gérez les membres de votre organisation.', 'organizations.members.description': 'Gérez les membres de votre organisation.',
@@ -418,13 +417,6 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys // API keys
'api-keys.permissions.select-all': 'Tout sélectionner',
'api-keys.permissions.deselect-all': 'Tout désélectionner',
'api-keys.permissions.organizations.title': 'Organisations',
'api-keys.permissions.organizations.organizations:create': 'Créer des organisations',
'api-keys.permissions.organizations.organizations:read': 'Lire des organisations',
'api-keys.permissions.organizations.organizations:update': 'Mettre à jour des organisations',
'api-keys.permissions.organizations.organizations:delete': 'Supprimer des organisations',
'api-keys.permissions.documents.title': 'Documents', 'api-keys.permissions.documents.title': 'Documents',
'api-keys.permissions.documents.documents:create': 'Créer des documents', 'api-keys.permissions.documents.documents:create': 'Créer des documents',
'api-keys.permissions.documents.documents:read': 'Lire des documents', 'api-keys.permissions.documents.documents:read': 'Lire des documents',
@@ -548,8 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors // API errors
'api-errors.document.already_exists': 'Le document existe déjà', 'api-errors.document.already_exists': 'Le document existe déjà',
'api-errors.document.size_too_large': 'Le fichier est trop volumineux', 'api-errors.document.file_too_big': 'Le fichier du document est trop grand',
'api-errors.intake-emails.already_exists': 'Un email de réception avec cette adresse existe déjà.',
'api-errors.intake_email.limit_reached': 'Le nombre maximum d\'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d\'emails de réception.', 'api-errors.intake_email.limit_reached': 'Le nombre maximum d\'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d\'emails de réception.',
'api-errors.user.max_organization_count_reached': 'Vous avez atteint le nombre maximum d\'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.', 'api-errors.user.max_organization_count_reached': 'Vous avez atteint le nombre maximum d\'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.',
'api-errors.default': 'Une erreur est survenue lors du traitement de votre requête.', 'api-errors.default': 'Une erreur est survenue lors du traitement de votre requête.',

View File

@@ -143,7 +143,6 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Elimina organizzazione', 'organization.settings.delete.confirm.confirm-button': 'Elimina organizzazione',
'organization.settings.delete.confirm.cancel-button': 'Annulla', 'organization.settings.delete.confirm.cancel-button': 'Annulla',
'organization.settings.delete.success': 'Organizzazione eliminata', 'organization.settings.delete.success': 'Organizzazione eliminata',
'organization.settings.delete.only-owner': 'Solo il proprietario dell\'organizzazione può eliminare questa organizzazione.',
'organizations.members.title': 'Membri', 'organizations.members.title': 'Membri',
'organizations.members.description': 'Gestisci i membri della tua organizzazione', 'organizations.members.description': 'Gestisci i membri della tua organizzazione',
@@ -418,13 +417,6 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys // API keys
'api-keys.permissions.select-all': 'Seleziona tutto',
'api-keys.permissions.deselect-all': 'Deseleziona tutto',
'api-keys.permissions.organizations.title': 'Organizzazioni',
'api-keys.permissions.organizations.organizations:create': 'Crea organizzazioni',
'api-keys.permissions.organizations.organizations:read': 'Leggi organizzazioni',
'api-keys.permissions.organizations.organizations:update': 'Aggiorna organizzazioni',
'api-keys.permissions.organizations.organizations:delete': 'Elimina organizzazioni',
'api-keys.permissions.documents.title': 'Documenti', 'api-keys.permissions.documents.title': 'Documenti',
'api-keys.permissions.documents.documents:create': 'Crea documenti', 'api-keys.permissions.documents.documents:create': 'Crea documenti',
'api-keys.permissions.documents.documents:read': 'Leggi documenti', 'api-keys.permissions.documents.documents:read': 'Leggi documenti',
@@ -548,8 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors // API errors
'api-errors.document.already_exists': 'Il documento esiste già', 'api-errors.document.already_exists': 'Il documento esiste già',
'api-errors.document.size_too_large': 'Il file è troppo grande', 'api-errors.document.file_too_big': 'Il file del documento è troppo grande',
'api-errors.intake-emails.already_exists': 'Un\'email di acquisizione con questo indirizzo esiste già.',
'api-errors.intake_email.limit_reached': 'È stato raggiunto il numero massimo di email di acquisizione per questa organizzazione. Aggiorna il tuo piano per crearne altre.', 'api-errors.intake_email.limit_reached': 'È stato raggiunto il numero massimo di email di acquisizione per questa organizzazione. Aggiorna il tuo piano per crearne altre.',
'api-errors.user.max_organization_count_reached': 'Hai raggiunto il numero massimo di organizzazioni che puoi creare, se hai bisogno di crearne altre contatta il supporto.', 'api-errors.user.max_organization_count_reached': 'Hai raggiunto il numero massimo di organizzazioni che puoi creare, se hai bisogno di crearne altre contatta il supporto.',
'api-errors.default': 'Si è verificato un errore durante l\'elaborazione della richiesta.', 'api-errors.default': 'Si è verificato un errore durante l\'elaborazione della richiesta.',

View File

@@ -143,7 +143,6 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Usuń organizację', 'organization.settings.delete.confirm.confirm-button': 'Usuń organizację',
'organization.settings.delete.confirm.cancel-button': 'Anuluj', 'organization.settings.delete.confirm.cancel-button': 'Anuluj',
'organization.settings.delete.success': 'Organizacja została usunięta', 'organization.settings.delete.success': 'Organizacja została usunięta',
'organization.settings.delete.only-owner': 'Tylko właściciel organizacji może usunąć tę organizację.',
'organizations.members.title': 'Członkowie', 'organizations.members.title': 'Członkowie',
'organizations.members.description': 'Zarządzaj członkami swojej organizacji', 'organizations.members.description': 'Zarządzaj członkami swojej organizacji',
@@ -418,13 +417,6 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys // API keys
'api-keys.permissions.select-all': 'Zaznacz wszystko',
'api-keys.permissions.deselect-all': 'Odznacz wszystko',
'api-keys.permissions.organizations.title': 'Organizacje',
'api-keys.permissions.organizations.organizations:create': 'Tworzenie organizacji',
'api-keys.permissions.organizations.organizations:read': 'Odczyt organizacji',
'api-keys.permissions.organizations.organizations:update': 'Aktualizacja organizacji',
'api-keys.permissions.organizations.organizations:delete': 'Usuwanie organizacji',
'api-keys.permissions.documents.title': 'Dokumenty', 'api-keys.permissions.documents.title': 'Dokumenty',
'api-keys.permissions.documents.documents:create': 'Tworzenie dokumentów', 'api-keys.permissions.documents.documents:create': 'Tworzenie dokumentów',
'api-keys.permissions.documents.documents:read': 'Odczyt dokumentów', 'api-keys.permissions.documents.documents:read': 'Odczyt dokumentów',
@@ -548,8 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors // API errors
'api-errors.document.already_exists': 'Dokument już istnieje', 'api-errors.document.already_exists': 'Dokument już istnieje',
'api-errors.document.size_too_large': 'Plik jest zbyt duży', 'api-errors.document.file_too_big': 'Plik dokumentu jest zbyt duży',
'api-errors.intake-emails.already_exists': 'Adres e-mail do przyjęć z tym adresem już istnieje.',
'api-errors.intake_email.limit_reached': 'Osiągnięto maksymalną liczbę adresów e-mail do przyjęć dla tej organizacji. Aby utworzyć więcej adresów e-mail do przyjęć, zaktualizuj swój plan.', 'api-errors.intake_email.limit_reached': 'Osiągnięto maksymalną liczbę adresów e-mail do przyjęć dla tej organizacji. Aby utworzyć więcej adresów e-mail do przyjęć, zaktualizuj swój plan.',
'api-errors.user.max_organization_count_reached': 'Osiągnięto maksymalną liczbę organizacji, które możesz utworzyć. Jeśli potrzebujesz utworzyć więcej, skontaktuj się z pomocą techniczną.', 'api-errors.user.max_organization_count_reached': 'Osiągnięto maksymalną liczbę organizacji, które możesz utworzyć. Jeśli potrzebujesz utworzyć więcej, skontaktuj się z pomocą techniczną.',
'api-errors.default': 'Wystąpił błąd podczas przetwarzania żądania.', 'api-errors.default': 'Wystąpił błąd podczas przetwarzania żądania.',

View File

@@ -143,7 +143,6 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Excluir organização', 'organization.settings.delete.confirm.confirm-button': 'Excluir organização',
'organization.settings.delete.confirm.cancel-button': 'Cancelar', 'organization.settings.delete.confirm.cancel-button': 'Cancelar',
'organization.settings.delete.success': 'Organização excluída', 'organization.settings.delete.success': 'Organização excluída',
'organization.settings.delete.only-owner': 'Apenas o proprietário da organização pode excluir esta organização.',
'organizations.members.title': 'Membros', 'organizations.members.title': 'Membros',
'organizations.members.description': 'Gerencie os membros da sua organização', 'organizations.members.description': 'Gerencie os membros da sua organização',
@@ -418,13 +417,6 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys // API keys
'api-keys.permissions.select-all': 'Selecionar tudo',
'api-keys.permissions.deselect-all': 'Desmarcar tudo',
'api-keys.permissions.organizations.title': 'Organizações',
'api-keys.permissions.organizations.organizations:create': 'Criar organizações',
'api-keys.permissions.organizations.organizations:read': 'Ler organizações',
'api-keys.permissions.organizations.organizations:update': 'Atualizar organizações',
'api-keys.permissions.organizations.organizations:delete': 'Excluir organizações',
'api-keys.permissions.documents.title': 'Documentos', 'api-keys.permissions.documents.title': 'Documentos',
'api-keys.permissions.documents.documents:create': 'Criar documentos', 'api-keys.permissions.documents.documents:create': 'Criar documentos',
'api-keys.permissions.documents.documents:read': 'Ler documentos', 'api-keys.permissions.documents.documents:read': 'Ler documentos',
@@ -548,8 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors // API errors
'api-errors.document.already_exists': 'O documento já existe', 'api-errors.document.already_exists': 'O documento já existe',
'api-errors.document.size_too_large': 'O arquivo é muito grande', 'api-errors.document.file_too_big': 'O arquivo do documento é muito grande',
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
'api-errors.intake_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.', 'api-errors.intake_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.',
'api-errors.user.max_organization_count_reached': 'Você atingiu o número máximo de organizações que pode criar. Se precisar criar mais, entre em contato com o suporte.', 'api-errors.user.max_organization_count_reached': 'Você atingiu o número máximo de organizações que pode criar. Se precisar criar mais, entre em contato com o suporte.',
'api-errors.default': 'Ocorreu um erro ao processar sua solicitação.', 'api-errors.default': 'Ocorreu um erro ao processar sua solicitação.',

View File

@@ -143,7 +143,6 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Eliminar organização', 'organization.settings.delete.confirm.confirm-button': 'Eliminar organização',
'organization.settings.delete.confirm.cancel-button': 'Cancelar', 'organization.settings.delete.confirm.cancel-button': 'Cancelar',
'organization.settings.delete.success': 'Organização eliminada', 'organization.settings.delete.success': 'Organização eliminada',
'organization.settings.delete.only-owner': 'Apenas o proprietário da organização pode eliminar esta organização.',
'organizations.members.title': 'Membros', 'organizations.members.title': 'Membros',
'organizations.members.description': 'Gira os membros da sua organização', 'organizations.members.description': 'Gira os membros da sua organização',
@@ -418,13 +417,6 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys // API keys
'api-keys.permissions.select-all': 'Selecionar tudo',
'api-keys.permissions.deselect-all': 'Desselecionar tudo',
'api-keys.permissions.organizations.title': 'Organizações',
'api-keys.permissions.organizations.organizations:create': 'Criar organizações',
'api-keys.permissions.organizations.organizations:read': 'Ler organizações',
'api-keys.permissions.organizations.organizations:update': 'Atualizar organizações',
'api-keys.permissions.organizations.organizations:delete': 'Eliminar organizações',
'api-keys.permissions.documents.title': 'Documentos', 'api-keys.permissions.documents.title': 'Documentos',
'api-keys.permissions.documents.documents:create': 'Criar documentos', 'api-keys.permissions.documents.documents:create': 'Criar documentos',
'api-keys.permissions.documents.documents:read': 'Ler documentos', 'api-keys.permissions.documents.documents:read': 'Ler documentos',
@@ -548,8 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors // API errors
'api-errors.document.already_exists': 'O documento já existe', 'api-errors.document.already_exists': 'O documento já existe',
'api-errors.document.size_too_large': 'O arquivo é muito grande', 'api-errors.document.file_too_big': 'O arquivo do documento é muito grande',
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
'api-errors.intake_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.', 'api-errors.intake_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.',
'api-errors.user.max_organization_count_reached': 'Atingiu o número máximo de organizações que pode criar. Se precisar de criar mais, entre em contato com o suporte.', 'api-errors.user.max_organization_count_reached': 'Atingiu o número máximo de organizações que pode criar. Se precisar de criar mais, entre em contato com o suporte.',
'api-errors.default': 'Ocorreu um erro ao processar a solicitação.', 'api-errors.default': 'Ocorreu um erro ao processar a solicitação.',

View File

@@ -143,7 +143,6 @@ export const translations: Partial<TranslationsDictionary> = {
'organization.settings.delete.confirm.confirm-button': 'Șterge organizație', 'organization.settings.delete.confirm.confirm-button': 'Șterge organizație',
'organization.settings.delete.confirm.cancel-button': 'Anulează', 'organization.settings.delete.confirm.cancel-button': 'Anulează',
'organization.settings.delete.success': 'Organizație ștearsă cu succes', 'organization.settings.delete.success': 'Organizație ștearsă cu succes',
'organization.settings.delete.only-owner': 'Doar proprietarul organizației poate șterge această organizație.',
'organizations.members.title': 'Membri', 'organizations.members.title': 'Membri',
'organizations.members.description': 'Gestionează membrii organizației tale', 'organizations.members.description': 'Gestionează membrii organizației tale',
@@ -418,13 +417,6 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys // API keys
'api-keys.permissions.select-all': 'Selectează tot',
'api-keys.permissions.deselect-all': 'Deselectează tot',
'api-keys.permissions.organizations.title': 'Organizații',
'api-keys.permissions.organizations.organizations:create': 'Creează organizații',
'api-keys.permissions.organizations.organizations:read': 'Citește organizații',
'api-keys.permissions.organizations.organizations:update': 'Actualizează organizații',
'api-keys.permissions.organizations.organizations:delete': 'Șterge organizații',
'api-keys.permissions.documents.title': 'Documente', 'api-keys.permissions.documents.title': 'Documente',
'api-keys.permissions.documents.documents:create': 'Creează documente', 'api-keys.permissions.documents.documents:create': 'Creează documente',
'api-keys.permissions.documents.documents:read': 'Citește documente', 'api-keys.permissions.documents.documents:read': 'Citește documente',
@@ -548,8 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors // API errors
'api-errors.document.already_exists': 'Documentul există deja', 'api-errors.document.already_exists': 'Documentul există deja',
'api-errors.document.size_too_large': 'Fișierul este prea mare', 'api-errors.document.file_too_big': 'Fișierul documentului este prea mare',
'api-errors.intake-emails.already_exists': 'Un email de primire cu această adresă există deja.',
'api-errors.intake_email.limit_reached': 'Numărul maxim de email-uri de primire pentru această organizație a fost atins. Te rugăm să-ți îmbunătățești planul pentru a crea mai multe email-uri de primire.', 'api-errors.intake_email.limit_reached': 'Numărul maxim de email-uri de primire pentru această organizație a fost atins. Te rugăm să-ți îmbunătățești planul pentru a crea mai multe email-uri de primire.',
'api-errors.user.max_organization_count_reached': 'Ai atins numărul maxim de organizații pe care le poți crea. Dacă ai nevoie să creezi mai multe, te rugăm să contactezi asistența.', 'api-errors.user.max_organization_count_reached': 'Ai atins numărul maxim de organizații pe care le poți crea. Dacă ai nevoie să creezi mai multe, te rugăm să contactezi asistența.',
'api-errors.default': 'A apărut o eroare la procesarea cererii.', 'api-errors.default': 'A apărut o eroare la procesarea cererii.',

View File

@@ -5,15 +5,6 @@
// } as const; // } as const;
export const API_KEY_PERMISSIONS = [ export const API_KEY_PERMISSIONS = [
{
section: 'organizations',
permissions: [
'organizations:create',
'organizations:read',
'organizations:update',
'organizations:delete',
],
},
{ {
section: 'documents', section: 'documents',
permissions: [ permissions: [

View File

@@ -2,7 +2,6 @@ import type { Component } from 'solid-js';
import type { TranslationKeys } from '@/modules/i18n/locales.types'; import type { TranslationKeys } from '@/modules/i18n/locales.types';
import { createSignal, For } from 'solid-js'; import { createSignal, For } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider'; import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox'; import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { API_KEY_PERMISSIONS } from '../api-keys.constants'; import { API_KEY_PERMISSIONS } from '../api-keys.constants';
@@ -43,78 +42,34 @@ export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChang
props.onChange(permissions()); props.onChange(permissions());
}; };
const toggleSection = (sectionName: typeof API_KEY_PERMISSIONS[number]['section']) => {
const section = API_KEY_PERMISSIONS.find(s => s.section === sectionName);
if (!section) {
return;
}
const sectionPermissions: ReadonlyArray<string> = section.permissions;
const currentPermissions = permissions();
const allSelected = sectionPermissions.every(p => currentPermissions.includes(p));
setPermissions((prev) => {
if (allSelected) {
return [...prev.filter(p => !sectionPermissions.includes(p))];
}
return [...new Set([...prev, ...sectionPermissions])];
});
};
return ( return (
<div class="p-6 pb-8 border rounded-md mt-2"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<For each={getPermissionsSections()}>
{section => (
<div>
<p class="text-muted-foreground text-xs">{section.title}</p>
<div class="flex flex-col gap-6"> <div class="pl-4 flex flex-col gap-4 mt-4">
<For each={getPermissionsSections()}> <For each={section.permissions}>
{section => ( {permission => (
<div> <Checkbox
<Button variant="link" class="text-muted-foreground text-xs p-0 h-auto hover:no-underline" onClick={() => toggleSection(section.section)}>{section.title}</Button> class="flex items-center gap-2"
checked={isPermissionSelected(permission.name)}
<div class="pl-4 flex flex-col mt-2"> onChange={() => togglePermission(permission.name)}
<For each={section.permissions}> >
{permission => ( <CheckboxControl />
<Checkbox <div class="flex flex-col gap-1">
class="flex items-center gap-2" <CheckboxLabel class="text-sm leading-none">
checked={isPermissionSelected(permission.name)} {permission.description}
onChange={() => togglePermission(permission.name)} </CheckboxLabel>
> </div>
<CheckboxControl /> </Checkbox>
<div class="flex flex-col gap-1"> )}
<CheckboxLabel class="text-sm leading-none py-1"> </For>
{permission.description}
</CheckboxLabel>
</div>
</Checkbox>
)}
</For>
</div>
</div> </div>
)} </div>
</For> )}
</div> </For>
<div class="flex items-center gap-2 mt-6 border-t pt-6">
<Button
variant="outline"
size="sm"
class="disabled:(op-100! border-op-50 text-muted-foreground)"
onClick={() => setPermissions(API_KEY_PERMISSIONS.flatMap(section => section.permissions))}
disabled={permissions().length === API_KEY_PERMISSIONS.flatMap(section => section.permissions).length}
>
{t('api-keys.permissions.select-all')}
</Button>
<Button
variant="outline"
size="sm"
class="disabled:(op-100! border-op-50 text-muted-foreground)"
onClick={() => setPermissions([])}
disabled={permissions().length === 0}
>
{t('api-keys.permissions.deselect-all')}
</Button>
</div>
</div> </div>
); );
}; };

View File

@@ -96,7 +96,9 @@ export const CreateApiKeyPage: Component = () => {
<div> <div>
<p class="text-sm font-bold">{t('api-keys.create.form.permissions.label')}</p> <p class="text-sm font-bold">{t('api-keys.create.form.permissions.label')}</p>
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} /> <div class="p-6 pb-8 border rounded-md mt-2">
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>} {field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</div> </div>

View File

@@ -38,9 +38,6 @@ export const buildTimeConfig = {
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false), isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false),
}, },
isSubscriptionsEnabled: asBoolean(import.meta.env.VITE_IS_SUBSCRIPTIONS_ENABLED, false), isSubscriptionsEnabled: asBoolean(import.meta.env.VITE_IS_SUBSCRIPTIONS_ENABLED, false),
documentsStorage: {
maxUploadSize: asNumber(import.meta.env.VITE_DOCUMENTS_STORAGE_MAX_UPLOAD_SIZE, 10 * 1024 * 1024),
},
} as const; } as const;
export type Config = typeof buildTimeConfig; export type Config = typeof buildTimeConfig;

View File

@@ -5,7 +5,6 @@ import { A } from '@solidjs/router';
import { throttle } from 'lodash-es'; import { throttle } from 'lodash-es';
import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js'; import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js';
import { Portal } from 'solid-js/web'; import { Portal } from 'solid-js/web';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider'; import { useI18n } from '@/modules/i18n/i18n.provider';
import { promptUploadFiles } from '@/modules/shared/files/upload'; import { promptUploadFiles } from '@/modules/shared/files/upload';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors'; import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
@@ -58,7 +57,6 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500); const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);
const { getErrorMessage } = useI18nApiErrors(); const { getErrorMessage } = useI18nApiErrors();
const { t } = useI18n(); const { t } = useI18n();
const { config } = useConfig();
const [getState, setState] = createSignal<'open' | 'closed' | 'collapsed'>('closed'); const [getState, setState] = createSignal<'open' | 'closed' | 'collapsed'>('closed');
const [getTasks, setTasks] = createSignal<Task[]>([]); const [getTasks, setTasks] = createSignal<Task[]>([]);
@@ -72,14 +70,8 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
setState('open'); setState('open');
await Promise.all(files.map(async (file) => { await Promise.all(files.map(async (file) => {
const { maxUploadSize } = config.documentsStorage;
updateTaskStatus({ file, status: 'uploading' }); updateTaskStatus({ file, status: 'uploading' });
if (maxUploadSize > 0 && file.size > maxUploadSize) {
updateTaskStatus({ file, status: 'error', error: Object.assign(new Error('File too large'), { code: 'document.size_too_large' }) });
return;
}
const [result, error] = await safely(uploadDocument({ file, organizationId })); const [result, error] = await safely(uploadDocument({ file, organizationId }));
if (error) { if (error) {

View File

@@ -1,9 +1,11 @@
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
import { useParams } from '@solidjs/router'; import { useParams } from '@solidjs/router';
import { createSignal } from 'solid-js'; import { createSignal } from 'solid-js';
import { promptUploadFiles } from '@/modules/shared/files/upload';
import { queryClient } from '@/modules/shared/query/query-client';
import { cn } from '@/modules/shared/style/cn'; import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button'; import { Button } from '@/modules/ui/components/button';
import { useDocumentUpload } from './document-import-status.component'; import { uploadDocument } from '../documents.services';
export const DocumentUploadArea: Component<{ organizationId?: string }> = (props) => { export const DocumentUploadArea: Component<{ organizationId?: string }> = (props) => {
const [isDragging, setIsDragging] = createSignal(false); const [isDragging, setIsDragging] = createSignal(false);
@@ -11,7 +13,21 @@ export const DocumentUploadArea: Component<{ organizationId?: string }> = (props
const getOrganizationId = () => props.organizationId ?? params.organizationId; const getOrganizationId = () => props.organizationId ?? params.organizationId;
const { promptImport, uploadDocuments } = useDocumentUpload({ getOrganizationId }); const uploadFiles = async ({ files }: { files: File[] }) => {
for (const file of files) {
await uploadDocument({ file, organizationId: getOrganizationId() });
}
await queryClient.invalidateQueries({
queryKey: ['organizations', getOrganizationId(), 'documents'],
refetchType: 'all',
});
};
const promptImport = async () => {
const { files } = await promptUploadFiles();
await uploadFiles({ files });
};
const handleDragOver = (event: DragEvent) => { const handleDragOver = (event: DragEvent) => {
event.preventDefault(); event.preventDefault();
@@ -30,7 +46,7 @@ export const DocumentUploadArea: Component<{ organizationId?: string }> = (props
} }
const files = [...event.dataTransfer.files].filter(file => file.type === 'application/pdf'); const files = [...event.dataTransfer.files].filter(file => file.type === 'application/pdf');
await uploadDocuments({ files }); await uploadFiles({ files });
}; };
return ( return (

View File

@@ -1,9 +1,13 @@
import type { Document } from './documents.types'; import type { Document } from './documents.types';
import { safely } from '@corentinth/chisels';
import { throttle } from 'lodash-es';
import { createSignal } from 'solid-js'; import { createSignal } from 'solid-js';
import { useConfirmModal } from '../shared/confirm'; import { useConfirmModal } from '../shared/confirm';
import { promptUploadFiles } from '../shared/files/upload';
import { isHttpErrorWithCode } from '../shared/http/http-errors';
import { queryClient } from '../shared/query/query-client'; import { queryClient } from '../shared/query/query-client';
import { createToast } from '../ui/components/sonner'; import { createToast } from '../ui/components/sonner';
import { deleteDocument, restoreDocument } from './documents.services'; import { deleteDocument, restoreDocument, uploadDocument } from './documents.services';
export function invalidateOrganizationDocumentsQuery({ organizationId }: { organizationId: string }) { export function invalidateOrganizationDocumentsQuery({ organizationId }: { organizationId: string }) {
return queryClient.invalidateQueries({ return queryClient.invalidateQueries({
@@ -72,3 +76,57 @@ export function useRestoreDocument() {
}, },
}; };
} }
function toastUploadError({ error, file }: { error: Error; file: File }) {
if (isHttpErrorWithCode({ error, code: 'document.already_exists' })) {
createToast({
type: 'error',
message: 'Document already exists',
description: `The document ${file.name} already exists, it has not been uploaded.`,
});
return;
}
if (isHttpErrorWithCode({ error, code: 'document.file_too_big' })) {
createToast({
type: 'error',
message: 'Document too big',
description: `The document ${file.name} is too big, it has not been uploaded.`,
});
return;
}
createToast({
type: 'error',
message: 'Failed to upload document',
description: error.message,
});
}
export function useUploadDocuments({ organizationId }: { organizationId: string }) {
const uploadDocuments = async ({ files }: { files: File[] }) => {
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);
await Promise.all(files.map(async (file) => {
const [, error] = await safely(uploadDocument({ file, organizationId }));
if (error) {
toastUploadError({ error, file });
}
await throttledInvalidateOrganizationDocumentsQuery({ organizationId });
}),
);
};
return {
uploadDocuments,
promptImport: async () => {
const { files } = await promptUploadFiles();
await uploadDocuments({ files });
},
};
}

View File

@@ -214,6 +214,15 @@ export const DocumentPage: Component = () => {
{t('documents.actions.download')} {t('documents.actions.download')}
</Button> </Button>
<Button
variant="outline"
onClick={() => window.open(getDataUrl()!, '_blank')}
size="sm"
>
<div class="i-tabler-eye size-4 mr-2"></div>
{t('documents.actions.open-in-new-tab')}
</Button>
{getDocument().isDeleted {getDocument().isDeleted
? ( ? (
<Button <Button

View File

@@ -1,4 +1,5 @@
import type { translations as defaultTranslations } from '@/locales/en.dictionary'; import { translations as defaultTranslations } from '@/locales/en.dictionary';
export type TranslationKeys = keyof typeof defaultTranslations; export type TranslationKeys = keyof typeof defaultTranslations;
export type TranslationsDictionary = Record<TranslationKeys, string>; export type TranslationsDictionary = Record<TranslationKeys, string>;

View File

@@ -10,7 +10,7 @@ import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider'; import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm'; import { useConfirmModal } from '@/modules/shared/confirm';
import { createForm } from '@/modules/shared/form/form'; import { createForm } from '@/modules/shared/form/form';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors'; import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
import { queryClient } from '@/modules/shared/query/query-client'; import { queryClient } from '@/modules/shared/query/query-client';
import { cn } from '@/modules/shared/style/cn'; import { cn } from '@/modules/shared/style/cn';
import { Alert, AlertDescription } from '@/modules/ui/components/alert'; import { Alert, AlertDescription } from '@/modules/ui/components/alert';
@@ -187,7 +187,6 @@ export const IntakeEmailsPage: Component = () => {
const params = useParams(); const params = useParams();
const { confirm } = useConfirmModal(); const { confirm } = useConfirmModal();
const { getErrorMessage } = useI18nApiErrors({ t });
const query = useQuery(() => ({ const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'intake-emails'], queryKey: ['organizations', params.organizationId, 'intake-emails'],
@@ -197,12 +196,16 @@ export const IntakeEmailsPage: Component = () => {
const createEmail = async () => { const createEmail = async () => {
const [,error] = await safely(createIntakeEmail({ organizationId: params.organizationId })); const [,error] = await safely(createIntakeEmail({ organizationId: params.organizationId }));
if (error) { if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
createToast({ createToast({
message: getErrorMessage({ error }), message: t('api-errors.intake_email.limit_reached'),
type: 'error', type: 'error',
}); });
return;
}
if (error) {
throw error; throw error;
} }

View File

@@ -3,9 +3,9 @@ import { formatBytes } from '@corentinth/chisels';
import { useParams } from '@solidjs/router'; import { useParams } from '@solidjs/router';
import { createQueries, keepPreviousData } from '@tanstack/solid-query'; import { createQueries, keepPreviousData } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js'; import { createSignal, Show, Suspense } from 'solid-js';
import { useDocumentUpload } from '@/modules/documents/components/document-import-status.component';
import { DocumentUploadArea } from '@/modules/documents/components/document-upload-area.component'; import { DocumentUploadArea } from '@/modules/documents/components/document-upload-area.component';
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '@/modules/documents/components/documents-list.component'; import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '@/modules/documents/components/documents-list.component';
import { useUploadDocuments } from '@/modules/documents/documents.composables';
import { fetchOrganizationDocuments, getOrganizationDocumentsStats } from '@/modules/documents/documents.services'; import { fetchOrganizationDocuments, getOrganizationDocumentsStats } from '@/modules/documents/documents.services';
import { useI18n } from '@/modules/i18n/i18n.provider'; import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button'; import { Button } from '@/modules/ui/components/button';
@@ -32,7 +32,7 @@ export const OrganizationPage: Component = () => {
], ],
})); }));
const { promptImport } = useDocumentUpload({ getOrganizationId: () => params.organizationId }); const { promptImport } = useUploadDocuments({ organizationId: params.organizationId });
return ( return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto"> <div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">

View File

@@ -15,7 +15,7 @@ import { Button } from '@/modules/ui/components/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/modules/ui/components/card'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/modules/ui/components/card';
import { createToast } from '@/modules/ui/components/sonner'; import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield'; import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { useCurrentUserRole, useDeleteOrganization, useUpdateOrganization } from '../organizations.composables'; import { useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
import { organizationNameSchema } from '../organizations.schemas'; import { organizationNameSchema } from '../organizations.schemas';
import { fetchOrganization } from '../organizations.services'; import { fetchOrganization } from '../organizations.services';
@@ -24,8 +24,6 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
const { confirm } = useConfirmModal(); const { confirm } = useConfirmModal();
const { t } = useI18n(); const { t } = useI18n();
const { getIsOwner, query } = useCurrentUserRole({ organizationId: props.organization.id });
const handleDelete = async () => { const handleDelete = async () => {
const confirmed = await confirm({ const confirmed = await confirm({
title: t('organization.settings.delete.confirm.title'), title: t('organization.settings.delete.confirm.title'),
@@ -56,16 +54,10 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardFooter class="pt-6 gap-4"> <CardFooter class="pt-6">
<Button onClick={handleDelete} variant="destructive" disabled={!getIsOwner()}> <Button onClick={handleDelete} variant="destructive">
{t('organization.settings.delete.confirm.confirm-button')} {t('organization.settings.delete.confirm.confirm-button')}
</Button> </Button>
<Show when={query.isSuccess && !getIsOwner()}>
<span class="text-sm text-muted-foreground">
{t('organization.settings.delete.only-owner')}
</span>
</Show>
</CardFooter> </CardFooter>
</Card> </Card>
</div> </div>

View File

@@ -1,6 +1,5 @@
import type { TranslationKeys } from '@/modules/i18n/locales.types'; import type { TranslationKeys } from '@/modules/i18n/locales.types';
import { get } from 'lodash-es'; import { get } from 'lodash-es';
import { FetchError } from 'ofetch';
import { useI18n } from '@/modules/i18n/i18n.provider'; import { useI18n } from '@/modules/i18n/i18n.provider';
function codeToKey(code: string): TranslationKeys { function codeToKey(code: string): TranslationKeys {
@@ -31,11 +30,6 @@ export function useI18nApiErrors({ t = useI18n().t }: { t?: ReturnType<typeof us
return translation; return translation;
} }
// Fetch error message is not helpful
if (error instanceof FetchError) {
return getDefaultErrorMessage();
}
if (typeof error === 'object' && error && 'message' in error && typeof error.message === 'string') { if (typeof error === 'object' && error && 'message' in error && typeof error.message === 'string') {
return error.message; return error.message;
} }

View File

@@ -1,9 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
env: {
TZ: 'UTC',
},
},
});

View File

@@ -1,88 +1,5 @@
# @papra/app-server # @papra/app-server
## 0.9.5
### Patch Changes
- [#521](https://github.com/papra-hq/papra/pull/521) [`b287723`](https://github.com/papra-hq/papra/commit/b28772317c3662555e598755b85597d6cd5aeea1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names encoding (utf8 instead of latin1) to support non-ASCII characters.
- [#517](https://github.com/papra-hq/papra/pull/517) [`a3f9f05`](https://github.com/papra-hq/papra/commit/a3f9f05c664b4995b62db59f2e9eda8a3bfef0de) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Prevented organization deletion by non-organization owner
## 0.9.4
### Patch Changes
- [#508](https://github.com/papra-hq/papra/pull/508) [`782f70f`](https://github.com/papra-hq/papra/commit/782f70ff663634bf9ff7218edabb9885a7c6f965) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added an option to disable PRAGMA statements from sqlite task service migrations
- [#510](https://github.com/papra-hq/papra/pull/510) [`ab6fd6a`](https://github.com/papra-hq/papra/commit/ab6fd6ad10387f1dcd626936efc195d9d58d40ec) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added fallbacks env variables for the task worker id
- [#512](https://github.com/papra-hq/papra/pull/512) [`cb3ce6b`](https://github.com/papra-hq/papra/commit/cb3ce6b1d8d5dba09cbf0d2964f14b1c93220571) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added organizations permissions for api keys
## 0.9.3
### Patch Changes
- [#506](https://github.com/papra-hq/papra/pull/506) [`6bcb2a7`](https://github.com/papra-hq/papra/commit/6bcb2a71e990d534dd12d84e64a38f2b2baea25a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to define patterns for email intake username generation
- [#504](https://github.com/papra-hq/papra/pull/504) [`936bc2b`](https://github.com/papra-hq/papra/commit/936bc2bd0a788e4fb0bceb6d14810f9f8734097b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Split the intake-email username generation from the email address creation, some changes regarding the configuration when using the `random` driver.
```env
# Old configuration
INTAKE_EMAILS_DRIVER=random-username
INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=mydomain.com
# New configuration
INTAKE_EMAILS_DRIVER=catch-all
INTAKE_EMAILS_CATCH_ALL_DOMAIN=mydomain.com
INTAKE_EMAILS_USERNAME_DRIVER=random
```
- [#504](https://github.com/papra-hq/papra/pull/504) [`936bc2b`](https://github.com/papra-hq/papra/commit/936bc2bd0a788e4fb0bceb6d14810f9f8734097b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to configure OwlRelay domain
## 0.9.2
### Patch Changes
- [#493](https://github.com/papra-hq/papra/pull/493) [`ed4d7e4`](https://github.com/papra-hq/papra/commit/ed4d7e4a00b2ca2c7fe808201c322f957d6ed990) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix to allow cross docker volume file moving when consumption is done
- [#500](https://github.com/papra-hq/papra/pull/500) [`208a561`](https://github.com/papra-hq/papra/commit/208a561668ed2d1019430a9f4f5c5d3fd4cde603) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to define a Libsql/Sqlite driver for the tasks service
- [#499](https://github.com/papra-hq/papra/pull/499) [`40cb1d7`](https://github.com/papra-hq/papra/commit/40cb1d71d5e52c40aab7ea2c6bc222cea6d55b70) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Enhanced security by serving files as attachement and with an octet-stream content type
## 0.9.1
### Patch Changes
- [#492](https://github.com/papra-hq/papra/pull/492) [`54514e1`](https://github.com/papra-hq/papra/commit/54514e15db5deaffc59dcba34929b5e2e74282e1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a client side guard for rejecting too-big files
- [#491](https://github.com/papra-hq/papra/pull/491) [`bb9d555`](https://github.com/papra-hq/papra/commit/bb9d5556d3f16225ae40ca4d39600999e819b2c4) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix cleanup state when a too-big-file is uploaded
## 0.9.0
### Minor Changes
- [#472](https://github.com/papra-hq/papra/pull/472) [`b08241f`](https://github.com/papra-hq/papra/commit/b08241f20fc326a65a8de0551a7bfa91d9e4c71d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Dropped support for the dedicated backblaze b2 storage driver as b2 now fully support s3 client
- [#480](https://github.com/papra-hq/papra/pull/480) [`0a03f42`](https://github.com/papra-hq/papra/commit/0a03f42231f691d339c7ab5a5916c52385e31bd2) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added documents encryption layer
- [#472](https://github.com/papra-hq/papra/pull/472) [`b08241f`](https://github.com/papra-hq/papra/commit/b08241f20fc326a65a8de0551a7bfa91d9e4c71d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Stream file upload instead of full in-memory loading
### Patch Changes
- [#481](https://github.com/papra-hq/papra/pull/481) [`1606310`](https://github.com/papra-hq/papra/commit/1606310745e8edf405b527127078143481419e8c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Allow for more complex intake-email origin adresses
- [#483](https://github.com/papra-hq/papra/pull/483) [`ec0a437`](https://github.com/papra-hq/papra/commit/ec0a437d86b4c8c0979ba9d0c2ff7b39f054cec0) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix a bug where the ingestion folder was not working when the done or error destination folder path (INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH and INGESTION_FOLDER_ERROR_FOLDER_PATH) were absolute.
- [#475](https://github.com/papra-hq/papra/pull/475) [`ea9d90d`](https://github.com/papra-hq/papra/commit/ea9d90d6cff6954297152b3ad16f99170e8cd0dc) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Use node file streams in ingestion folder for smaller RAM footprint
- [#477](https://github.com/papra-hq/papra/pull/477) [`a62d376`](https://github.com/papra-hq/papra/commit/a62d3767729ab02ae203a1ac7b7fd6eb6e011d98) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed an issue where tags assigned to only deleted documents won't show up in the tag list
- [#472](https://github.com/papra-hq/papra/pull/472) [`b08241f`](https://github.com/papra-hq/papra/commit/b08241f20fc326a65a8de0551a7bfa91d9e4c71d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle missing files errors in storage drivers
- Updated dependencies [[`14bc2b8`](https://github.com/papra-hq/papra/commit/14bc2b8f8d0d6605062f37188e7c57bbc61b2c1a)]:
- @papra/webhooks@0.3.0
- @papra/lecture@0.2.0
## 0.8.2 ## 0.8.2
### Patch Changes ### Patch Changes

View File

@@ -1,7 +1,7 @@
{ {
"name": "@papra/app-server", "name": "@papra/app-server",
"type": "module", "type": "module",
"version": "0.9.5", "version": "0.8.2",
"private": true, "private": true,
"packageManager": "pnpm@10.12.3", "packageManager": "pnpm@10.12.3",
"description": "Papra app server", "description": "Papra app server",
@@ -42,7 +42,6 @@
"@aws-sdk/lib-storage": "^3.835.0", "@aws-sdk/lib-storage": "^3.835.0",
"@azure/storage-blob": "^12.27.0", "@azure/storage-blob": "^12.27.0",
"@cadence-mq/core": "^0.2.1", "@cadence-mq/core": "^0.2.1",
"@cadence-mq/driver-libsql": "^0.2.4",
"@cadence-mq/driver-memory": "^0.2.0", "@cadence-mq/driver-memory": "^0.2.0",
"@corentinth/chisels": "^1.3.1", "@corentinth/chisels": "^1.3.1",
"@corentinth/friendly-ids": "^0.0.1", "@corentinth/friendly-ids": "^0.0.1",
@@ -55,7 +54,6 @@
"@papra/lecture": "workspace:*", "@papra/lecture": "workspace:*",
"@papra/webhooks": "workspace:*", "@papra/webhooks": "workspace:*",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@sindresorhus/slugify": "^3.0.0",
"better-auth": "catalog:", "better-auth": "catalog:",
"busboy": "^1.6.0", "busboy": "^1.6.0",
"c12": "^3.0.4", "c12": "^3.0.4",
@@ -63,7 +61,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"drizzle-orm": "^0.38.4", "drizzle-orm": "^0.38.4",
"figue": "^3.1.1", "figue": "^2.2.3",
"hono": "^4.8.2", "hono": "^4.8.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",

View File

@@ -21,8 +21,6 @@ const { db, client } = setupDatabase(config.database);
const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage }); const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
const taskServices = createTaskServices({ config }); const taskServices = createTaskServices({ config });
await taskServices.initialize();
const { app } = await createServer({ config, db, taskServices, documentsStorageService }); const { app } = await createServer({ config, db, taskServices, documentsStorageService });
const server = serve( const server = serve(

View File

@@ -5,15 +5,8 @@ export const API_KEY_ID_REGEX = createPrefixedIdRegex({ prefix: API_KEY_ID_PREFI
export const API_KEY_PREFIX = 'ppapi'; export const API_KEY_PREFIX = 'ppapi';
export const API_KEY_TOKEN_LENGTH = 64; export const API_KEY_TOKEN_LENGTH = 64;
export const API_KEY_TOKEN_REGEX = new RegExp(`^${API_KEY_PREFIX}_[A-Za-z0-9]{${API_KEY_TOKEN_LENGTH}}$`);
export const API_KEY_PERMISSIONS = { export const API_KEY_PERMISSIONS = {
ORGANIZATIONS: {
CREATE: 'organizations:create',
READ: 'organizations:read',
UPDATE: 'organizations:update',
DELETE: 'organizations:delete',
},
DOCUMENTS: { DOCUMENTS: {
CREATE: 'documents:create', CREATE: 'documents:create',
READ: 'documents:read', READ: 'documents:read',

View File

@@ -4,7 +4,6 @@ import { createMiddleware } from 'hono/factory';
import { createUnauthorizedError } from '../app/auth/auth.errors'; import { createUnauthorizedError } from '../app/auth/auth.errors';
import { getAuthorizationHeader } from '../shared/headers/headers.models'; import { getAuthorizationHeader } from '../shared/headers/headers.models';
import { isNil } from '../shared/utils'; import { isNil } from '../shared/utils';
import { looksLikeAnApiKey } from './api-keys.models';
import { createApiKeysRepository } from './api-keys.repository'; import { createApiKeysRepository } from './api-keys.repository';
import { getApiKey } from './api-keys.usecases'; import { getApiKey } from './api-keys.usecases';
@@ -32,7 +31,8 @@ export function createApiKeyMiddleware({ db }: { db: Database }) {
throw createUnauthorizedError(); throw createUnauthorizedError();
} }
if (!looksLikeAnApiKey(token)) { if (isNil(token)) {
// For type safety
throw createUnauthorizedError(); throw createUnauthorizedError();
} }

View File

@@ -1,6 +1,5 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { getApiKeyUiPrefix, looksLikeAnApiKey } from './api-keys.models'; import { getApiKeyUiPrefix } from './api-keys.models';
import { generateApiToken } from './api-keys.services';
describe('api-keys models', () => { describe('api-keys models', () => {
describe('getApiKeyUiPrefix', () => { describe('getApiKeyUiPrefix', () => {
@@ -12,39 +11,4 @@ describe('api-keys models', () => {
); );
}); });
}); });
describe('looksLikeAnApiKey', () => {
test(`validate that a token looks like an api key
- it starts with the api key prefix
- it has the correct length
- it only contains alphanumeric characters`, () => {
expect(
looksLikeAnApiKey('ppapi_29qxv9eCbRkQQGhwrVZCEXEFjOYpXZX07G4vDK4HT03Jp7fVHyJx1b0l6e1LIEPD'),
).toBe(true);
expect(
looksLikeAnApiKey(''),
).toBe(false);
expect(
looksLikeAnApiKey('ppapi_'),
).toBe(false);
expect(
looksLikeAnApiKey('ppapi_29qxv9eCbRkQQGhwrVZCEXEFjOYpXZX07G4vDK4HT03Jp7fVHyJx1b0l6e1LIEPD_extra'),
).toBe(false);
expect(
looksLikeAnApiKey('invalidprefix_29qxv9eCbRkQQGhwrVZCEXEFjOYpXZX07G4vDK4HT03Jp7fVHyJx1b0l6e1LIEPD'),
).toBe(false);
});
test('a freshly generated token should always look like an api key', () => {
const { token } = generateApiToken();
expect(
looksLikeAnApiKey(token),
).toBe(true);
});
});
}); });

View File

@@ -1,6 +1,5 @@
import { sha256 } from '../shared/crypto/hash'; import { sha256 } from '../shared/crypto/hash';
import { isNil } from '../shared/utils'; import { API_KEY_PREFIX } from './api-keys.constants';
import { API_KEY_PREFIX, API_KEY_TOKEN_REGEX } from './api-keys.constants';
export function getApiKeyUiPrefix({ token }: { token: string }) { export function getApiKeyUiPrefix({ token }: { token: string }) {
return { return {
@@ -13,12 +12,3 @@ export function getApiKeyHash({ token }: { token: string }) {
keyHash: sha256(token, { digest: 'base64url' }), keyHash: sha256(token, { digest: 'base64url' }),
}; };
} }
// Positional argument as TS does not like named argument with type guards
export function looksLikeAnApiKey(token?: string | null | undefined): token is string {
if (isNil(token)) {
return false;
}
return API_KEY_TOKEN_REGEX.test(token);
}

View File

@@ -1,7 +1,7 @@
import type { Context, RouteDefinitionContext } from '../server.types'; import type { Context, RouteDefinitionContext } from '../server.types';
import type { Session } from './auth.types'; import type { Session } from './auth.types';
import { get } from 'lodash-es'; import { get } from 'lodash-es';
import { isDefined, isString } from '../../shared/utils'; import { isDefined } from '../../shared/utils';
export function registerAuthRoutes({ app, auth, config }: RouteDefinitionContext) { export function registerAuthRoutes({ app, auth, config }: RouteDefinitionContext) {
app.on( app.on(
@@ -26,7 +26,7 @@ export function registerAuthRoutes({ app, auth, config }: RouteDefinitionContext
app.use('*', async (context: Context, next) => { app.use('*', async (context: Context, next) => {
const overrideUserId: unknown = get(context.env, 'loggedInUserId'); const overrideUserId: unknown = get(context.env, 'loggedInUserId');
if (isDefined(overrideUserId) && isString(overrideUserId)) { if (isDefined(overrideUserId) && typeof overrideUserId === 'string') {
context.set('userId', overrideUserId); context.set('userId', overrideUserId);
context.set('session', {} as Session); context.set('session', {} as Session);
context.set('authType', 'session'); context.set('authType', 'session');

View File

@@ -69,9 +69,6 @@ describe('config models', () => {
intakeEmails: { intakeEmails: {
isEnabled: true, isEnabled: true,
}, },
documentsStorage: {
maxUploadSize: 10485760,
},
}, },
}); });
}); });

View File

@@ -13,7 +13,6 @@ export function getPublicConfig({ config }: { config: Config }) {
'auth.providers.github.isEnabled', 'auth.providers.github.isEnabled',
'auth.providers.google.isEnabled', 'auth.providers.google.isEnabled',
'documents.deletedDocumentsRetentionDays', 'documents.deletedDocumentsRetentionDays',
'documentsStorage.maxUploadSize',
'intakeEmails.isEnabled', 'intakeEmails.isEnabled',
]), ]),
{ {

View File

@@ -15,7 +15,6 @@ import { intakeEmailsConfig } from '../intake-emails/intake-emails.config';
import { organizationsConfig } from '../organizations/organizations.config'; import { organizationsConfig } from '../organizations/organizations.config';
import { organizationPlansConfig } from '../plans/plans.config'; import { organizationPlansConfig } from '../plans/plans.config';
import { createLogger } from '../shared/logger/logger'; import { createLogger } from '../shared/logger/logger';
import { isString } from '../shared/utils';
import { subscriptionsConfig } from '../subscriptions/subscriptions.config'; import { subscriptionsConfig } from '../subscriptions/subscriptions.config';
import { tasksConfig } from '../tasks/tasks.config'; import { tasksConfig } from '../tasks/tasks.config';
import { trackingConfig } from '../tracking/tracking.config'; import { trackingConfig } from '../tracking/tracking.config';
@@ -72,7 +71,7 @@ export const configDefinition = {
schema: z.union([ schema: z.union([
z.string(), z.string(),
z.array(z.string()), z.array(z.string()),
]).transform(value => (isString(value) ? value.split(',') : value)), ]).transform(value => (typeof value === 'string' ? value.split(',') : value)),
default: ['http://localhost:3000'], default: ['http://localhost:3000'],
env: 'SERVER_CORS_ORIGINS', env: 'SERVER_CORS_ORIGINS',
}, },

View File

@@ -1,21 +0,0 @@
import { Buffer } from 'node:buffer';
import { describe, expect, test } from 'vitest';
import { MULTIPART_FORM_DATA_SINGLE_FILE_CONTENT_LENGTH_OVERHEAD } from './documents.constants';
const unusuallyLongFileName = 'an-unusually-long-file-name-in-order-to-test-the-content-length-header-with-the-metadata-that-are-included-in-the-form-data-so-lorem-ipsum-dolor-sit-amet-consectetur-adipiscing-elit-sed-do-eiusmod-tempor-incididunt-ut-labore-et-dolore-magna-aliqua-ut-enim-ad-minim-veniam-quis-nostrud-exercitation-ullamco-laboris-nisi-ut-aliquip-ex-ea-commodo-consequat-duis-aute-irure-dolor-in-reprehenderit-in-voluptate-velit-esse-cillum-dolore-eu-fugiat-nulla-pariatur-excepteur-sint-occaecat-proident-in-voluptate-velit-esse-cillum-dolore-eu-fugiat-nulla-pariatur-excepteur-sint-occaecat-proident-in-voluptate-velit-esse-cillum-dolore-eu-fugiat-nulla-pariatur-excepteur-sint-occaecat-proident.txt';
describe('documents constants', () => {
// eslint-disable-next-line test/prefer-lowercase-title
describe('MULTIPART_FORM_DATA_SINGLE_FILE_CONTENT_LENGTH_OVERHEAD', () => {
test('when uploading a formdata multipart, the body has boundaries and other metadata, so the content length is greater than the file size', async () => {
const fileSize = 100;
const formData = new FormData();
formData.append('file', new File(['a'.repeat(fileSize)], unusuallyLongFileName, { type: 'text/plain' }));
const body = new Response(formData);
const contentLength = Buffer.from(await body.arrayBuffer()).length;
expect(contentLength).to.be.greaterThan(fileSize);
expect(contentLength).to.be.lessThan(fileSize + MULTIPART_FORM_DATA_SINGLE_FILE_CONTENT_LENGTH_OVERHEAD);
});
});
});

View File

@@ -11,6 +11,3 @@ export const ORIGINAL_DOCUMENTS_STORAGE_KEY = 'originals';
// import { ocrLanguages } from '@papra/lecture'; // import { ocrLanguages } from '@papra/lecture';
// console.log(JSON.stringify(ocrLanguages)); // console.log(JSON.stringify(ocrLanguages));
export const OCR_LANGUAGES = ['afr', 'amh', 'ara', 'asm', 'aze', 'aze_cyrl', 'bel', 'ben', 'bod', 'bos', 'bul', 'cat', 'ceb', 'ces', 'chi_sim', 'chi_tra', 'chr', 'cym', 'dan', 'deu', 'dzo', 'ell', 'eng', 'enm', 'epo', 'est', 'eus', 'fas', 'fin', 'fra', 'frk', 'frm', 'gle', 'glg', 'grc', 'guj', 'hat', 'heb', 'hin', 'hrv', 'hun', 'iku', 'ind', 'isl', 'ita', 'ita_old', 'jav', 'jpn', 'kan', 'kat', 'kat_old', 'kaz', 'khm', 'kir', 'kor', 'kur', 'lao', 'lat', 'lav', 'lit', 'mal', 'mar', 'mkd', 'mlt', 'msa', 'mya', 'nep', 'nld', 'nor', 'ori', 'pan', 'pol', 'por', 'pus', 'ron', 'rus', 'san', 'sin', 'slk', 'slv', 'spa', 'spa_old', 'sqi', 'srp', 'srp_latn', 'swa', 'swe', 'syr', 'tam', 'tel', 'tgk', 'tgl', 'tha', 'tir', 'tur', 'uig', 'ukr', 'urd', 'uzb', 'uzb_cyrl', 'vie', 'yid'] as const; export const OCR_LANGUAGES = ['afr', 'amh', 'ara', 'asm', 'aze', 'aze_cyrl', 'bel', 'ben', 'bod', 'bos', 'bul', 'cat', 'ceb', 'ces', 'chi_sim', 'chi_tra', 'chr', 'cym', 'dan', 'deu', 'dzo', 'ell', 'eng', 'enm', 'epo', 'est', 'eus', 'fas', 'fin', 'fra', 'frk', 'frm', 'gle', 'glg', 'grc', 'guj', 'hat', 'heb', 'hin', 'hrv', 'hun', 'iku', 'ind', 'isl', 'ita', 'ita_old', 'jav', 'jpn', 'kan', 'kat', 'kat_old', 'kaz', 'khm', 'kir', 'kor', 'kur', 'lao', 'lat', 'lav', 'lit', 'mal', 'mar', 'mkd', 'mlt', 'msa', 'mya', 'nep', 'nld', 'nor', 'ori', 'pan', 'pol', 'por', 'pus', 'ron', 'rus', 'san', 'sin', 'slk', 'slv', 'spa', 'spa_old', 'sqi', 'srp', 'srp_latn', 'swa', 'swe', 'syr', 'tam', 'tel', 'tgk', 'tgl', 'tha', 'tir', 'tur', 'uig', 'ukr', 'urd', 'uzb', 'uzb_cyrl', 'vie', 'yid'] as const;
// When uploading a formdata multipart, the body has boundaries and other metadata that need to be accounted for
export const MULTIPART_FORM_DATA_SINGLE_FILE_CONTENT_LENGTH_OVERHEAD = 1024; // 1024 bytes

View File

@@ -13,7 +13,7 @@ import { deferTriggerWebhooks } from '../webhooks/webhook.usecases';
import { createDocumentActivityRepository } from './document-activity/document-activity.repository'; import { createDocumentActivityRepository } from './document-activity/document-activity.repository';
import { deferRegisterDocumentActivityLog } from './document-activity/document-activity.usecases'; import { deferRegisterDocumentActivityLog } from './document-activity/document-activity.usecases';
import { createDocumentIsNotDeletedError } from './documents.errors'; import { createDocumentIsNotDeletedError } from './documents.errors';
import { formatDocumentForApi, formatDocumentsForApi, isDocumentSizeLimitEnabled } from './documents.models'; import { formatDocumentForApi, formatDocumentsForApi } from './documents.models';
import { createDocumentsRepository } from './documents.repository'; import { createDocumentsRepository } from './documents.repository';
import { documentIdSchema } from './documents.schemas'; import { documentIdSchema } from './documents.schemas';
import { createDocumentCreationUsecase, deleteAllTrashDocuments, deleteTrashDocument, ensureDocumentExists, getDocumentOrThrow } from './documents.usecases'; import { createDocumentCreationUsecase, deleteAllTrashDocuments, deleteTrashDocument, ensureDocumentExists, getDocumentOrThrow } from './documents.usecases';
@@ -34,8 +34,6 @@ export function registerDocumentsRoutes(context: RouteDefinitionContext) {
} }
function setupCreateDocumentRoute({ app, ...deps }: RouteDefinitionContext) { function setupCreateDocumentRoute({ app, ...deps }: RouteDefinitionContext) {
const { config } = deps;
app.post( app.post(
'/api/organizations/:organizationId/documents', '/api/organizations/:organizationId/documents',
requireAuthentication({ apiKeyPermissions: ['documents:create'] }), requireAuthentication({ apiKeyPermissions: ['documents:create'] }),
@@ -46,15 +44,12 @@ function setupCreateDocumentRoute({ app, ...deps }: RouteDefinitionContext) {
const { userId } = getUser({ context }); const { userId } = getUser({ context });
const { organizationId } = context.req.valid('param'); const { organizationId } = context.req.valid('param');
const { maxUploadSize } = config.documentsStorage;
const { fileStream, fileName, mimeType } = await getFileStreamFromMultipartForm({ const { fileStream, fileName, mimeType } = await getFileStreamFromMultipartForm({
body: context.req.raw.body, body: context.req.raw.body,
headers: context.req.header(), headers: context.req.header(),
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : undefined,
}); });
const createDocument = createDocumentCreationUsecase({ ...deps }); const createDocument = await createDocumentCreationUsecase({ ...deps });
const { document } = await createDocument({ fileStream, fileName, mimeType, userId, organizationId }); const { document } = await createDocument({ fileStream, fileName, mimeType, userId, organizationId });
@@ -288,13 +283,9 @@ function setupGetDocumentFileRoute({ app, db, documentsStorageService }: RouteDe
Readable.toWeb(fileStream), Readable.toWeb(fileStream),
200, 200,
{ {
// Prevent XSS by serving the file as an octet-stream 'Content-Type': document.mimeType,
'Content-Type': 'application/octet-stream', 'Content-Disposition': `inline; filename*=UTF-8''${encodeURIComponent(document.name)}`,
// Always use attachment for defense in depth - client uses blob API anyway
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(document.name)}`,
'Content-Length': String(document.originalSize), 'Content-Length': String(document.originalSize),
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
}, },
); );
}, },

View File

@@ -35,7 +35,7 @@ describe('documents usecases', () => {
}); });
const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage }); const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
const createDocument = createDocumentCreationUsecase({ const createDocument = await createDocumentCreationUsecase({
db, db,
config, config,
generateDocumentId: () => 'doc_1', generateDocumentId: () => 'doc_1',
@@ -96,7 +96,7 @@ describe('documents usecases', () => {
const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage }); const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
let documentIdIndex = 1; let documentIdIndex = 1;
const createDocument = createDocumentCreationUsecase({ const createDocument = await createDocumentCreationUsecase({
db, db,
config, config,
generateDocumentId: () => `doc_${documentIdIndex++}`, generateDocumentId: () => `doc_${documentIdIndex++}`,
@@ -201,7 +201,7 @@ describe('documents usecases', () => {
organizationPlans: { isFreePlanUnlimited: true }, organizationPlans: { isFreePlanUnlimited: true },
}); });
const createDocument = createDocumentCreationUsecase({ const createDocument = await createDocumentCreationUsecase({
db, db,
config, config,
taskServices, taskServices,
@@ -256,7 +256,7 @@ describe('documents usecases', () => {
const documentsRepository = createDocumentsRepository({ db }); const documentsRepository = createDocumentsRepository({ db });
const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage }); const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
const createDocument = createDocumentCreationUsecase({ const createDocument = await createDocumentCreationUsecase({
documentsStorageService, documentsStorageService,
db, db,
config, config,
@@ -305,7 +305,7 @@ describe('documents usecases', () => {
}); });
let documentIdIndex = 1; let documentIdIndex = 1;
const createDocument = createDocumentCreationUsecase({ const createDocument = await createDocumentCreationUsecase({
db, db,
config, config,
generateDocumentId: () => `doc_${documentIdIndex++}`, generateDocumentId: () => `doc_${documentIdIndex++}`,
@@ -369,7 +369,7 @@ describe('documents usecases', () => {
}), }),
} as PlansRepository; } as PlansRepository;
const createDocument = createDocumentCreationUsecase({ const createDocument = await createDocumentCreationUsecase({
db, db,
config: overrideConfig(), config: overrideConfig(),
taskServices, taskServices,
@@ -434,7 +434,7 @@ describe('documents usecases', () => {
}), }),
} as PlansRepository; } as PlansRepository;
const createDocument = createDocumentCreationUsecase({ const createDocument = await createDocumentCreationUsecase({
db, db,
config: overrideConfig(), config: overrideConfig(),
taskServices, taskServices,
@@ -492,7 +492,7 @@ describe('documents usecases', () => {
}), }),
} as PlansRepository; } as PlansRepository;
const createDocument = createDocumentCreationUsecase({ const createDocument = await createDocumentCreationUsecase({
db, db,
config: overrideConfig(), config: overrideConfig(),
taskServices, taskServices,

View File

@@ -14,8 +14,6 @@ import type { DocumentsRepository } from './documents.repository';
import type { Document } from './documents.types'; import type { Document } from './documents.types';
import type { DocumentStorageService } from './storage/documents.storage.services'; import type { DocumentStorageService } from './storage/documents.storage.services';
import type { EncryptionContext } from './storage/drivers/drivers.models'; import type { EncryptionContext } from './storage/drivers/drivers.models';
import { PassThrough } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { safely } from '@corentinth/chisels'; import { safely } from '@corentinth/chisels';
import pLimit from 'p-limit'; import pLimit from 'p-limit';
import { createOrganizationDocumentStorageLimitReachedError } from '../organizations/organizations.errors'; import { createOrganizationDocumentStorageLimitReachedError } from '../organizations/organizations.errors';
@@ -103,27 +101,17 @@ export async function createDocument({
}, },
}); });
// Create a PassThrough stream that will be used for saving the file const outputStream = fileStream
// This allows us to use pipeline for better error handling .pipe(hashStream)
const outputStream = new PassThrough(); .pipe(byteCountStream);
const streamProcessingPromise = pipeline(
fileStream,
hashStream,
byteCountStream,
outputStream,
);
// We optimistically save the file to leverage streaming, if the file already exists, we will delete it // We optimistically save the file to leverage streaming, if the file already exists, we will delete it
const [newFileStorageContext] = await Promise.all([ const newFileStorageContext = await documentsStorageService.saveFile({
documentsStorageService.saveFile({ fileStream: outputStream,
fileStream: outputStream, storageKey: originalDocumentStorageKey,
storageKey: originalDocumentStorageKey, mimeType,
mimeType, fileName,
fileName, });
}),
streamProcessingPromise,
]);
const hash = getHash(); const hash = getHash();
const size = getByteCount(); const size = getByteCount();
@@ -188,7 +176,7 @@ export async function createDocument({
export type CreateDocumentUsecase = Awaited<ReturnType<typeof createDocumentCreationUsecase>>; export type CreateDocumentUsecase = Awaited<ReturnType<typeof createDocumentCreationUsecase>>;
export type DocumentUsecaseDependencies = Omit<Parameters<typeof createDocument>[0], 'fileStream' | 'fileName' | 'mimeType' | 'userId' | 'organizationId'>; export type DocumentUsecaseDependencies = Omit<Parameters<typeof createDocument>[0], 'fileStream' | 'fileName' | 'mimeType' | 'userId' | 'organizationId'>;
export function createDocumentCreationUsecase({ export async function createDocumentCreationUsecase({
db, db,
config, config,
taskServices, taskServices,

View File

@@ -127,71 +127,5 @@ describe('documents e2e', () => {
// Ensure no file is saved in the storage // Ensure no file is saved in the storage
expect(documentsStorageService._getStorage().size).to.eql(0); expect(documentsStorageService._getStorage().size).to.eql(0);
}); });
// https://github.com/papra-hq/papra/issues/519
test('uploading documents with various UTF-8 characters in filenames', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
organizations: [{ id: 'org_222222222222222222222222', name: 'Org 1' }],
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
});
const { app } = await createServer({
db,
config: overrideConfig({
env: 'test',
documentsStorage: {
driver: 'in-memory',
},
}),
});
// Various UTF-8 characters that cause encoding issues
const testCases = [
{ filename: 'ΒΕΒΑΙΩΣΗ ΧΑΡΕΣ.txt', content: 'Filename with Greek characters' },
{ filename: 'résumé français.txt', content: 'French document' },
{ filename: 'documento español.txt', content: 'Spanish document' },
{ filename: '日本語ファイル.txt', content: 'Japanese document' },
{ filename: 'файл на русском.txt', content: 'Russian document' },
{ filename: 'émojis 🎉📄.txt', content: 'Document with emojis' },
];
for (const testCase of testCases) {
const formData = new FormData();
formData.append('file', new File([testCase.content], testCase.filename, { type: 'text/plain' }));
const body = new Response(formData);
const createDocumentResponse = await app.request(
'/api/organizations/org_222222222222222222222222/documents',
{
method: 'POST',
headers: {
...Object.fromEntries(body.headers.entries()),
},
body: await body.arrayBuffer(),
},
{ loggedInUserId: 'usr_111111111111111111111111' },
);
expect(createDocumentResponse.status).to.eql(200);
const { document } = (await createDocumentResponse.json()) as { document: Document };
// Each filename should be preserved correctly
expect(document.name).to.eql(testCase.filename);
expect(document.originalName).to.eql(testCase.filename);
// Retrieve the document
const getDocumentResponse = await app.request(
`/api/organizations/org_222222222222222222222222/documents/${document.id}`,
{ method: 'GET' },
{ loggedInUserId: 'usr_111111111111111111111111' },
);
expect(getDocumentResponse.status).to.eql(200);
const { document: retrievedDocument } = (await getDocumentResponse.json()) as { document: Document };
expect(retrievedDocument).to.eql({ ...document, tags: [] });
}
});
}); });
}); });

View File

@@ -0,0 +1,12 @@
import { documentsTable } from "../documents.table";
import { DocumentStorageService } from "./documents.storage.services";
export async function migrateDocumentsStorage({db, inputDocumentStorageService, outputDocumentStorageService, logger = createLogger({ namespace: 'migrateDocumentsStorage' })}: {
db: Database;
inputDocumentStorageService: DocumentStorageService;
outputDocumentStorageService: DocumentStorageService;
logger?: Logger;
}) {
}

View File

@@ -2,6 +2,7 @@ import type { DocumentStorageConfig } from '../../documents.storage.types';
import { AzuriteContainer } from '@testcontainers/azurite'; import { AzuriteContainer } from '@testcontainers/azurite';
import { describe } from 'vitest'; import { describe } from 'vitest';
import { TEST_CONTAINER_IMAGES } from '../../../../../../test/containers/images'; import { TEST_CONTAINER_IMAGES } from '../../../../../../test/containers/images';
import { overrideConfig } from '../../../../config/config.test-utils';
import { runDriverTestSuites } from '../drivers.test-suite'; import { runDriverTestSuites } from '../drivers.test-suite';
import { azBlobStorageDriverFactory } from './az-blob.storage-driver'; import { azBlobStorageDriverFactory } from './az-blob.storage-driver';

View File

@@ -1,4 +1,5 @@
import type { Readable } from 'node:stream'; import type { Readable } from 'node:stream';
import type { Config } from '../../../config/config.types';
import type { ExtendNamedArguments, ExtendReturnPromise } from '../../../shared/types'; import type { ExtendNamedArguments, ExtendReturnPromise } from '../../../shared/types';
import type { DocumentStorageConfig } from '../documents.storage.types'; import type { DocumentStorageConfig } from '../documents.storage.types';

View File

@@ -1,3 +1,4 @@
import type { Config } from '../../../../config/config.types';
import type { DocumentStorageConfig } from '../../documents.storage.types'; import type { DocumentStorageConfig } from '../../documents.storage.types';
import fs from 'node:fs'; import fs from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';

View File

@@ -37,14 +37,6 @@ export const fsStorageDriverFactory = defineStorageDriver(({ documentStorageConf
writeStream.on('error', (error) => { writeStream.on('error', (error) => {
reject(error); reject(error);
}); });
// Listen for errors on the input stream as well
fileStream.on('error', (error) => {
// Clean up the write stream and file
writeStream.destroy();
fs.unlink(storagePath, () => {}); // Ignore errors when cleaning up
reject(error);
});
}); });
}, },
getFileStream: async ({ storageKey }) => { getFileStream: async ({ storageKey }) => {

View File

@@ -3,6 +3,7 @@ import { CreateBucketCommand } from '@aws-sdk/client-s3';
import { LocalstackContainer } from '@testcontainers/localstack'; import { LocalstackContainer } from '@testcontainers/localstack';
import { describe } from 'vitest'; import { describe } from 'vitest';
import { TEST_CONTAINER_IMAGES } from '../../../../../../test/containers/images'; import { TEST_CONTAINER_IMAGES } from '../../../../../../test/containers/images';
import { overrideConfig } from '../../../../config/config.test-utils';
import { runDriverTestSuites } from '../drivers.test-suite'; import { runDriverTestSuites } from '../drivers.test-suite';
import { s3StorageDriverFactory } from './s3.storage-driver'; import { s3StorageDriverFactory } from './s3.storage-driver';

View File

@@ -3,7 +3,6 @@ import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, S3Client } fr
import { Upload } from '@aws-sdk/lib-storage'; import { Upload } from '@aws-sdk/lib-storage';
import { safely } from '@corentinth/chisels'; import { safely } from '@corentinth/chisels';
import { isString } from '../../../../shared/utils';
import { createFileNotFoundError } from '../../document-storage.errors'; import { createFileNotFoundError } from '../../document-storage.errors';
import { defineStorageDriver } from '../drivers.models'; import { defineStorageDriver } from '../drivers.models';
@@ -13,7 +12,7 @@ function isS3NotFoundError(error: Error) {
const codes = ['NoSuchKey', 'NotFound']; const codes = ['NoSuchKey', 'NotFound'];
return codes.includes(error.name) return codes.includes(error.name)
|| ('Code' in error && isString(error.Code) && codes.includes(error.Code)); || ('Code' in error && typeof error.Code === 'string' && codes.includes(error.Code));
} }
export const s3StorageDriverFactory = defineStorageDriver(({ documentStorageConfig }) => { export const s3StorageDriverFactory = defineStorageDriver(({ documentStorageConfig }) => {

View File

@@ -23,7 +23,7 @@ describe('document-encryption usecases', () => {
const storageDriver = inMemoryStorageDriverFactory(); const storageDriver = inMemoryStorageDriverFactory();
const createDocumentWithoutEncryption = createDocumentCreationUsecase({ const createDocumentWithoutEncryption = await createDocumentCreationUsecase({
db, db,
config: overrideConfig(), config: overrideConfig(),
taskServices: noopTaskServices, taskServices: noopTaskServices,
@@ -61,7 +61,7 @@ describe('document-encryption usecases', () => {
}, },
}); });
const createDocumentWithEncryption = createDocumentCreationUsecase({ const createDocumentWithEncryption = await createDocumentCreationUsecase({
db, db,
documentsStorageService: documentStorageServiceWithEncryption, documentsStorageService: documentStorageServiceWithEncryption,
config: overrideConfig(), config: overrideConfig(),

View File

@@ -1,9 +1,14 @@
import type { Logger } from '@crowlog/logger'; import type { Logger } from '@crowlog/logger';
import type { Database } from '../../../app/database/database.types'; import type { Database } from '../../../app/database/database.types';
import type { Config } from '../../../config/config.types';
import type { DocumentStorageService } from '../documents.storage.services'; import type { DocumentStorageService } from '../documents.storage.services';
import { eq, isNull } from 'drizzle-orm'; import { eq, isNotNull, isNull } from 'drizzle-orm';
import { createLogger } from '../../../shared/logger/logger'; import { createLogger } from '../../../shared/logger/logger';
import { documentsTable } from '../../documents.table'; import { documentsTable } from '../../documents.table';
import {
createDocumentStorageService,
} from '../documents.storage.services';
export async function encryptAllUnencryptedDocuments({ export async function encryptAllUnencryptedDocuments({
db, db,

View File

@@ -1,7 +1,6 @@
import type { ConfigDefinition } from 'figue'; import type { ConfigDefinition } from 'figue';
import { z } from 'zod'; import { z } from 'zod';
import { booleanishSchema } from '../config/config.schemas'; import { booleanishSchema } from '../config/config.schemas';
import { isString } from '../shared/utils';
import { defaultIgnoredPatterns } from './ingestion-folders.constants'; import { defaultIgnoredPatterns } from './ingestion-folders.constants';
export const ingestionFolderConfig = { export const ingestionFolderConfig = {
@@ -62,7 +61,7 @@ export const ingestionFolderConfig = {
schema: z.union([ schema: z.union([
z.string(), z.string(),
z.array(z.string()), z.array(z.string()),
]).transform(value => (isString(value) ? value.split(',') : value)), ]).transform(value => (typeof value === 'string' ? value.split(',') : value)),
default: defaultIgnoredPatterns, default: defaultIgnoredPatterns,
env: 'INGESTION_FOLDER_IGNORED_PATTERNS', env: 'INGESTION_FOLDER_IGNORED_PATTERNS',
}, },

Some files were not shown because too many files have changed in this diff Show More