Compare commits

...

13 Commits

Author SHA1 Message Date
Corentin Thomasset
2efe7321cd chore(release): update versions (#494)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-14 11:31:31 +02:00
Corentin Thomasset
947bdf8385 docs(CONTRIBUTING): add IDE setup instructions for ESLint in VS Code (#502)
* docs(CONTRIBUTING): add IDE setup instructions for ESLint in VS Code

* Update CONTRIBUTING.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-14 09:18:37 +00:00
Corentin Thomasset
b5bf0cca4b fix(upload): disable client size guard when maxUploadSize <= 0 (#501) 2025-09-14 10:44:29 +02:00
Corentin Thomasset
208a561668 feat(tasks): added libsql task service driver (#500) 2025-09-13 22:42:08 +02:00
Corentin Thomasset
40cb1d71d5 fix(documents): enhance file fetching security by setting appropriate headers (#499) 2025-09-13 15:46:34 +02:00
Corentin Thomasset
3da13f7591 refactor(document-page): remove "open in new tab" button (#498) 2025-09-13 15:29:51 +02:00
Corentin Thomasset
2a444aad31 chore(tests): set timezone in vitest configurations (#497) 2025-09-13 09:25:40 +00:00
Corentin Thomasset
47d8bbd356 refactor(utils): added isString and isNonEmptyString utility functions (#495) 2025-09-12 22:22:01 +02:00
Corentin Thomasset
ed4d7e4a00 fix(folder-ingestion): allow cross docker volume file moving (#493) 2025-09-10 22:48:56 +02:00
Corentin Thomasset
f382397c0e chore(release): update versions (#489)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-10 15:38:36 +02:00
Corentin Thomasset
54514e15db fix(translations): update error messages for file size limits across multiple languages (#492) 2025-09-10 15:35:34 +02:00
Corentin Thomasset
bb9d5556d3 fix(upload): properly handle file-too-big errors (#491) 2025-09-10 14:57:46 +02:00
Corentin Thomasset
83e943c5b4 refactor(client): update favicons (#488) 2025-09-09 23:30:27 +02:00
89 changed files with 778 additions and 182 deletions

2
.gitignore vendored
View File

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

View File

@@ -105,6 +105,73 @@ We recommend running the app locally for development. Follow these steps:
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
We use **Vitest** for testing. Each package comes with its own testing commands.

View File

@@ -1,5 +1,25 @@
# @papra/app-client
## 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

View File

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

View File

@@ -27,10 +27,23 @@
<meta property="twitter:image" content="https://papra.app/og-image.png">
<!-- Favicon and Icons -->
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png">
<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) -->
<script type="application/ld+json">

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-client",
"type": "module",
"version": "0.9.0",
"version": "0.9.2",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra frontend client",
@@ -21,12 +21,10 @@
"serve": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "pnpm check-i18n-types-outdated && vitest run",
"test": "vitest run",
"test:watch": "vitest watch",
"test:e2e": "playwright test",
"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"
},
"dependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,2 @@
<?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.

After

Width:  |  Height:  |  Size: 831 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,41 @@
{
"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.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -540,7 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.document.already_exists': 'Das Dokument existiert bereits',
'api-errors.document.file_too_big': 'Die Dokumentdatei ist zu groß',
'api-errors.document.size_too_large': 'Die Datei ist zu groß',
'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.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.',

View File

@@ -538,7 +538,7 @@ export const translations = {
// API errors
'api-errors.document.already_exists': 'The document already exists',
'api-errors.document.file_too_big': 'The document file is too big',
'api-errors.document.size_too_large': 'The file size is too large',
'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.default': 'An error occurred while processing your request.',

View File

@@ -540,7 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.document.already_exists': 'El documento ya existe',
'api-errors.document.file_too_big': 'El archivo del documento es demasiado grande',
'api-errors.document.size_too_large': 'El archivo es demasiado grande',
'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.default': 'Ocurrió un error al procesar tu solicitud.',

View File

@@ -540,7 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.document.already_exists': 'Le document existe déjà',
'api-errors.document.file_too_big': 'Le fichier du document est trop grand',
'api-errors.document.size_too_large': 'Le fichier est trop volumineux',
'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.default': 'Une erreur est survenue lors du traitement de votre requête.',

View File

@@ -540,7 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.document.already_exists': 'Il documento esiste già',
'api-errors.document.file_too_big': 'Il file del documento è troppo grande',
'api-errors.document.size_too_large': 'Il file è troppo grande',
'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.default': 'Si è verificato un errore durante l\'elaborazione della richiesta.',

View File

@@ -540,7 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.document.already_exists': 'Dokument już istnieje',
'api-errors.document.file_too_big': 'Plik dokumentu jest zbyt duży',
'api-errors.document.size_too_large': 'Plik jest zbyt duży',
'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.default': 'Wystąpił błąd podczas przetwarzania żądania.',

View File

@@ -540,7 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.document.already_exists': 'O documento já existe',
'api-errors.document.file_too_big': 'O arquivo do documento é muito grande',
'api-errors.document.size_too_large': 'O arquivo é muito grande',
'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.default': 'Ocorreu um erro ao processar sua solicitação.',

View File

@@ -540,7 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.document.already_exists': 'O documento já existe',
'api-errors.document.file_too_big': 'O arquivo do documento é muito grande',
'api-errors.document.size_too_large': 'O arquivo é muito grande',
'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.default': 'Ocorreu um erro ao processar a solicitação.',

View File

@@ -540,7 +540,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.document.already_exists': 'Documentul există deja',
'api-errors.document.file_too_big': 'Fișierul documentului este prea mare',
'api-errors.document.size_too_large': 'Fișierul este prea mare',
'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.default': 'A apărut o eroare la procesarea cererii.',

View File

@@ -38,6 +38,9 @@ export const buildTimeConfig = {
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_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;
export type Config = typeof buildTimeConfig;

View File

@@ -5,6 +5,7 @@ import { A } from '@solidjs/router';
import { throttle } from 'lodash-es';
import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js';
import { Portal } from 'solid-js/web';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { promptUploadFiles } from '@/modules/shared/files/upload';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
@@ -57,6 +58,7 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);
const { getErrorMessage } = useI18nApiErrors();
const { t } = useI18n();
const { config } = useConfig();
const [getState, setState] = createSignal<'open' | 'closed' | 'collapsed'>('closed');
const [getTasks, setTasks] = createSignal<Task[]>([]);
@@ -70,8 +72,14 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
setState('open');
await Promise.all(files.map(async (file) => {
const { maxUploadSize } = config.documentsStorage;
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 }));
if (error) {

View File

@@ -1,11 +1,9 @@
import type { Component } from 'solid-js';
import { useParams } from '@solidjs/router';
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 { Button } from '@/modules/ui/components/button';
import { uploadDocument } from '../documents.services';
import { useDocumentUpload } from './document-import-status.component';
export const DocumentUploadArea: Component<{ organizationId?: string }> = (props) => {
const [isDragging, setIsDragging] = createSignal(false);
@@ -13,21 +11,7 @@ export const DocumentUploadArea: Component<{ organizationId?: string }> = (props
const getOrganizationId = () => props.organizationId ?? params.organizationId;
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 { promptImport, uploadDocuments } = useDocumentUpload({ getOrganizationId });
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
@@ -46,7 +30,7 @@ export const DocumentUploadArea: Component<{ organizationId?: string }> = (props
}
const files = [...event.dataTransfer.files].filter(file => file.type === 'application/pdf');
await uploadFiles({ files });
await uploadDocuments({ files });
};
return (

View File

@@ -1,13 +1,9 @@
import type { Document } from './documents.types';
import { safely } from '@corentinth/chisels';
import { throttle } from 'lodash-es';
import { createSignal } from 'solid-js';
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 { createToast } from '../ui/components/sonner';
import { deleteDocument, restoreDocument, uploadDocument } from './documents.services';
import { deleteDocument, restoreDocument } from './documents.services';
export function invalidateOrganizationDocumentsQuery({ organizationId }: { organizationId: string }) {
return queryClient.invalidateQueries({
@@ -76,57 +72,3 @@ 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,15 +214,6 @@ export const DocumentPage: Component = () => {
{t('documents.actions.download')}
</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
? (
<Button

View File

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

View File

@@ -3,9 +3,9 @@ import { formatBytes } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
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 { 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 { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
@@ -32,7 +32,7 @@ export const OrganizationPage: Component = () => {
],
}));
const { promptImport } = useUploadDocuments({ organizationId: params.organizationId });
const { promptImport } = useDocumentUpload({ getOrganizationId: () => params.organizationId });
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">

View File

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

View File

@@ -1,5 +1,23 @@
# @papra/app-server
## 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

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-server",
"type": "module",
"version": "0.9.0",
"version": "0.9.2",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra app server",
@@ -42,6 +42,7 @@
"@aws-sdk/lib-storage": "^3.835.0",
"@azure/storage-blob": "^12.27.0",
"@cadence-mq/core": "^0.2.1",
"@cadence-mq/driver-libsql": "^0.2.1",
"@cadence-mq/driver-memory": "^0.2.0",
"@corentinth/chisels": "^1.3.1",
"@corentinth/friendly-ids": "^0.0.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
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,3 +11,6 @@ export const ORIGINAL_DOCUMENTS_STORAGE_KEY = 'originals';
// import { ocrLanguages } from '@papra/lecture';
// 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;
// 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 { deferRegisterDocumentActivityLog } from './document-activity/document-activity.usecases';
import { createDocumentIsNotDeletedError } from './documents.errors';
import { formatDocumentForApi, formatDocumentsForApi } from './documents.models';
import { formatDocumentForApi, formatDocumentsForApi, isDocumentSizeLimitEnabled } from './documents.models';
import { createDocumentsRepository } from './documents.repository';
import { documentIdSchema } from './documents.schemas';
import { createDocumentCreationUsecase, deleteAllTrashDocuments, deleteTrashDocument, ensureDocumentExists, getDocumentOrThrow } from './documents.usecases';
@@ -34,6 +34,8 @@ export function registerDocumentsRoutes(context: RouteDefinitionContext) {
}
function setupCreateDocumentRoute({ app, ...deps }: RouteDefinitionContext) {
const { config } = deps;
app.post(
'/api/organizations/:organizationId/documents',
requireAuthentication({ apiKeyPermissions: ['documents:create'] }),
@@ -44,9 +46,12 @@ function setupCreateDocumentRoute({ app, ...deps }: RouteDefinitionContext) {
const { userId } = getUser({ context });
const { organizationId } = context.req.valid('param');
const { maxUploadSize } = config.documentsStorage;
const { fileStream, fileName, mimeType } = await getFileStreamFromMultipartForm({
body: context.req.raw.body,
headers: context.req.header(),
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : undefined,
});
const createDocument = createDocumentCreationUsecase({ ...deps });
@@ -283,9 +288,13 @@ function setupGetDocumentFileRoute({ app, db, documentsStorageService }: RouteDe
Readable.toWeb(fileStream),
200,
{
'Content-Type': document.mimeType,
'Content-Disposition': `inline; filename*=UTF-8''${encodeURIComponent(document.name)}`,
// Prevent XSS by serving the file as an octet-stream
'Content-Type': 'application/octet-stream',
// 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),
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
},
);
},

View File

@@ -14,6 +14,8 @@ import type { DocumentsRepository } from './documents.repository';
import type { Document } from './documents.types';
import type { DocumentStorageService } from './storage/documents.storage.services';
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 pLimit from 'p-limit';
import { createOrganizationDocumentStorageLimitReachedError } from '../organizations/organizations.errors';
@@ -101,17 +103,27 @@ export async function createDocument({
},
});
const outputStream = fileStream
.pipe(hashStream)
.pipe(byteCountStream);
// Create a PassThrough stream that will be used for saving the file
// This allows us to use pipeline for better error handling
const outputStream = new PassThrough();
const streamProcessingPromise = pipeline(
fileStream,
hashStream,
byteCountStream,
outputStream,
);
// We optimistically save the file to leverage streaming, if the file already exists, we will delete it
const newFileStorageContext = await documentsStorageService.saveFile({
fileStream: outputStream,
storageKey: originalDocumentStorageKey,
mimeType,
fileName,
});
const [newFileStorageContext] = await Promise.all([
documentsStorageService.saveFile({
fileStream: outputStream,
storageKey: originalDocumentStorageKey,
mimeType,
fileName,
}),
streamProcessingPromise,
]);
const hash = getHash();
const size = getByteCount();

View File

@@ -37,6 +37,14 @@ export const fsStorageDriverFactory = defineStorageDriver(({ documentStorageConf
writeStream.on('error', (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 }) => {

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import type { FsNative } from './fs.services';
import { memfs } from 'memfs';
import { createFsServices } from './fs.services';
export function createInMemoryFsServices(volume: NestedDirectoryJSON) {
export function buildInMemoryFs(volume: NestedDirectoryJSON) {
const { vol } = memfs(volume);
const fs = {
@@ -12,7 +12,16 @@ export function createInMemoryFsServices(volume: NestedDirectoryJSON) {
} as FsNative;
return {
fs,
getFsState: () => vol.toJSON(),
};
}
export function createInMemoryFsServices(volume: NestedDirectoryJSON) {
const { fs, getFsState } = buildInMemoryFs(volume);
return {
getFsState,
fs: createFsServices({ fs }),
};
}

View File

@@ -0,0 +1,12 @@
import { isNil, isString } from '../utils';
export function isCrossDeviceError({ error }: { error: Error & { code?: unknown } }) {
if (isNil(error.code) || !isString(error.code)) {
return false;
}
return [
'EXDEV', // Linux based OS (see `man rename`)
'ERROR_NOT_SAME_DEVICE', // Windows
].includes(error.code);
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, test } from 'vitest';
import { buildInMemoryFs } from './fs.in-memory';
import { moveFile } from './fs.services';
describe('fs services', () => {
describe('moveFile', () => {
test('moves a file from the source path to the destination path', async () => {
const { fs, getFsState } = buildInMemoryFs({
'/file.txt': 'test content',
});
await moveFile({
sourceFilePath: '/file.txt',
destinationFilePath: '/renamed.txt',
fs,
});
expect(getFsState()).to.eql({
'/renamed.txt': 'test content',
});
});
test('if the destination file is in a different partition or disk, or a different docker volume, the underlying rename operation fails with an EXDEV error, so we fallback to copy + delete the source file', async () => {
const { fs, getFsState } = buildInMemoryFs({
'/file.txt': 'test content',
});
await moveFile({
sourceFilePath: '/file.txt',
destinationFilePath: '/renamed.txt',
fs: {
...fs,
rename: async () => {
// Simulate an EXDEV error
throw Object.assign(new Error('EXDEV'), { code: 'EXDEV' });
},
},
});
expect(getFsState()).to.eql({
'/renamed.txt': 'test content',
});
});
});
});

View File

@@ -2,8 +2,9 @@ import type { Readable } from 'node:stream';
import { Buffer } from 'node:buffer';
import fsSyncNative from 'node:fs';
import fsPromisesNative from 'node:fs/promises';
import { injectArguments } from '@corentinth/chisels';
import { injectArguments, safely } from '@corentinth/chisels';
import { pick } from 'lodash-es';
import { isCrossDeviceError } from './fs.models';
// what we use from the native fs module
export type FsNative = {
@@ -13,12 +14,13 @@ export type FsNative = {
stat: (path: string) => Promise<{ size: number }>;
readFile: (path: string) => Promise<Buffer>;
access: (path: string, mode: number) => Promise<void>;
copyFile: (sourcePath: string, destinationPath: string) => Promise<void>;
constants: { F_OK: number };
createReadStream: (path: string) => Readable;
};
const fsNative = {
...pick(fsPromisesNative, 'mkdir', 'unlink', 'rename', 'readFile', 'access', 'constants', 'stat'),
...pick(fsPromisesNative, 'mkdir', 'unlink', 'rename', 'readFile', 'access', 'constants', 'stat', 'copyFile'),
createReadStream: fsSyncNative.createReadStream.bind(fsSyncNative) as (filePath: string) => Readable,
} as FsNative;
@@ -66,7 +68,19 @@ export async function deleteFile({ filePath, fs = fsNative }: { filePath: string
}
export async function moveFile({ sourceFilePath, destinationFilePath, fs = fsNative }: { sourceFilePath: string; destinationFilePath: string; fs?: FsNative }) {
await fs.rename(sourceFilePath, destinationFilePath);
const [, error] = await safely(fs.rename(sourceFilePath, destinationFilePath));
// With different docker volumes, the rename operation fails with an EXDEV error,
// so we fallback to copy and delete the source file
if (error && isCrossDeviceError({ error })) {
await fs.copyFile(sourceFilePath, destinationFilePath);
await fs.unlink(sourceFilePath);
return;
}
if (error) {
throw error;
}
}
export async function readFile({ filePath, fs = fsNative }: { filePath: string; fs?: FsNative }) {

View File

@@ -1,4 +1,5 @@
import type { Context } from '../../app/server.types';
import { isNil } from '../utils';
export function getHeader({ context, name }: { context: Context; name: string }) {
return context.req.header(name);
@@ -15,3 +16,13 @@ export function getImpersonatedUserIdFromHeader({ context }: { context: Context
return { impersonatedUserId };
}
export function getContentLengthHeader({ headers }: { headers: Record<string, string> }): number | undefined {
const contentLengthHeaderValue = headers['content-length'] ?? headers['Content-Length'];
if (isNil(contentLengthHeaderValue)) {
return undefined;
}
return Number(contentLengthHeaderValue);
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from 'vitest';
import { isContentLengthPessimisticallyTooLarge } from './file-upload';
describe('file-upload', () => {
describe('isContentLengthPessimisticallyTooLarge', () => {
test(`a file upload request is considered pessimistically too large when
- a content length header is present
- a max file size limit is provided
- the content length is greater than the max file size limit plus an over-estimated overhead due to the multipart form data (boundaries, metadata, etc)`, () => {
expect(
isContentLengthPessimisticallyTooLarge({
contentLength: 1_000,
maxFileSize: 1_000,
overhead: 512,
}),
).to.eql(false);
expect(
isContentLengthPessimisticallyTooLarge({
contentLength: undefined,
maxFileSize: 1_000,
overhead: 512,
}),
).to.eql(false);
expect(
isContentLengthPessimisticallyTooLarge({
contentLength: 1_000,
maxFileSize: undefined,
overhead: 512,
}),
).to.eql(false);
expect(
isContentLengthPessimisticallyTooLarge({
contentLength: undefined,
maxFileSize: undefined,
overhead: 512,
}),
).to.eql(false);
expect(
isContentLengthPessimisticallyTooLarge({
contentLength: 1_513,
maxFileSize: 1_000,
overhead: 512,
}),
).to.eql(true);
});
});
});

View File

@@ -1,18 +1,41 @@
import type { Logger } from '../logger/logger';
import { Readable } from 'node:stream';
import createBusboy from 'busboy';
import { MULTIPART_FORM_DATA_SINGLE_FILE_CONTENT_LENGTH_OVERHEAD } from '../../documents/documents.constants';
import { createDocumentSizeTooLargeError } from '../../documents/documents.errors';
import { createError } from '../errors/errors';
import { getContentLengthHeader } from '../headers/headers.models';
import { createLogger } from '../logger/logger';
import { isNil } from '../utils';
// Early check to avoid parsing the stream if the content length is set and too large
export function isContentLengthPessimisticallyTooLarge({
contentLength,
maxFileSize,
overhead = MULTIPART_FORM_DATA_SINGLE_FILE_CONTENT_LENGTH_OVERHEAD,
}: {
contentLength?: number;
maxFileSize?: number;
overhead?: number;
}) {
if (isNil(contentLength) || isNil(maxFileSize)) {
return false;
}
return contentLength > maxFileSize + overhead;
}
export async function getFileStreamFromMultipartForm({
body,
headers,
fieldName = 'file',
maxFileSize,
logger = createLogger({ namespace: 'file-upload' }),
}: {
body: ReadableStream | null | undefined;
headers: Record<string, string>;
fieldName?: string;
maxFileSize?: number;
logger?: Logger;
}) {
if (!body) {
@@ -23,12 +46,20 @@ export async function getFileStreamFromMultipartForm({
});
}
const contentLength = getContentLengthHeader({ headers });
if (isContentLengthPessimisticallyTooLarge({ contentLength, maxFileSize })) {
logger.debug({ contentLength, maxFileSize }, 'Content length is pessimistically too large');
throw createDocumentSizeTooLargeError();
}
const { promise, resolve, reject } = Promise.withResolvers<{ fileStream: Readable; fileName: string; mimeType: string }>();
const bb = createBusboy({
headers,
limits: {
files: 1, // Only allow one file
fileSize: maxFileSize,
},
})
.on('file', (formFieldname, fileStream, info) => {
@@ -44,6 +75,11 @@ export async function getFileStreamFromMultipartForm({
}));
}
fileStream.on('limit', () => {
logger.info({ contentLength, maxFileSize }, 'File stream limit reached');
fileStream.destroy(createDocumentSizeTooLargeError());
});
resolve({
fileStream,
fileName: info.filename,

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { isDefined, isNil, omitUndefined } from './utils';
import { isDefined, isNil, isNonEmptyString, isString, omitUndefined } from './utils';
describe('utils', () => {
describe('omitUndefined', () => {
@@ -47,4 +47,38 @@ describe('utils', () => {
expect(isDefined({})).toBe(true);
});
});
describe('isString', () => {
test('returns true if the value is a string', () => {
expect(isString('')).toBe(true);
expect(isString('foo')).toBe(true);
expect(isString(String(1))).toBe(true);
});
test('returns false if the value is not a string', () => {
expect(isString(undefined)).toBe(false);
expect(isString(null)).toBe(false);
expect(isString(0)).toBe(false);
expect(isString(false)).toBe(false);
expect(isString({})).toBe(false);
expect(isString([])).toBe(false);
});
});
describe('isNonEmptyString', () => {
test('returns true if the value is a non-empty string', () => {
expect(isNonEmptyString('')).toBe(false);
expect(isNonEmptyString('foo')).toBe(true);
expect(isNonEmptyString(String(1))).toBe(true);
});
test('returns false if the value is not a non-empty string', () => {
expect(isNonEmptyString(undefined)).toBe(false);
expect(isNonEmptyString(null)).toBe(false);
expect(isNonEmptyString(0)).toBe(false);
expect(isNonEmptyString(false)).toBe(false);
expect(isNonEmptyString({})).toBe(false);
expect(isNonEmptyString([])).toBe(false);
});
});
});

View File

@@ -15,3 +15,11 @@ export function isNil(value: unknown): value is undefined | null {
export function isDefined<T>(value: T): value is Exclude<T, undefined | null> {
return !isNil(value);
}
export function isString(value: unknown): value is string {
return typeof value === 'string';
}
export function isNonEmptyString(value: unknown): value is string {
return isString(value) && value.length > 0;
}

View File

@@ -1,4 +1,4 @@
import { isNil } from '../shared/utils';
import { isNil, isNonEmptyString } from '../shared/utils';
export function coerceStripeTimestampToDate(timestamp: number) {
return new Date(timestamp * 1000);
@@ -9,5 +9,5 @@ export function isSignatureHeaderFormatValid(signature: string | undefined): sig
return false;
}
return typeof signature === 'string' && signature.length > 0;
return isNonEmptyString(signature);
}

View File

@@ -0,0 +1,17 @@
import type { TaskPersistenceConfig, TaskServiceDriverDefinition } from '../../tasks.types';
import { createLibSqlDriver, setupSchema } from '@cadence-mq/driver-libsql';
import { createClient } from '@libsql/client';
export function createLibSqlTaskServiceDriver({ taskPersistenceConfig }: { taskPersistenceConfig: TaskPersistenceConfig }): TaskServiceDriverDefinition {
const { url, authToken, pollIntervalMs } = taskPersistenceConfig.drivers.libSql;
const client = createClient({ url, authToken });
const driver = createLibSqlDriver({ client, pollIntervalMs });
return {
driver,
initialize: async () => {
await setupSchema({ client });
},
};
}

View File

@@ -0,0 +1,10 @@
import type { TaskServiceDriverDefinition } from '../../tasks.types';
import { createMemoryDriver } from '@cadence-mq/driver-memory';
export function createMemoryTaskServiceDriver(): TaskServiceDriverDefinition {
const driver = createMemoryDriver();
return {
driver,
};
}

View File

@@ -0,0 +1,8 @@
export const TASKS_DRIVER_NAMES = {
memory: 'memory',
libsql: 'libsql',
} as const;
export const tasksDriverNames = Object.keys(TASKS_DRIVER_NAMES);
export type TasksDriverName = keyof typeof TASKS_DRIVER_NAMES;

View File

@@ -0,0 +1,9 @@
import type { TaskServiceDriverFactory } from '../tasks.types';
import { createLibSqlTaskServiceDriver } from './libsql/libsql.tasks-driver';
import { createMemoryTaskServiceDriver } from './memory/memory.tasks-driver';
import { TASKS_DRIVER_NAMES } from './tasks-driver.constants';
export const tasksDrivers = {
[TASKS_DRIVER_NAMES.memory]: createMemoryTaskServiceDriver,
[TASKS_DRIVER_NAMES.libsql]: createLibSqlTaskServiceDriver,
} as const satisfies Record<string, TaskServiceDriverFactory>;

View File

@@ -1,15 +1,39 @@
import type { ConfigDefinition } from 'figue';
import type { TasksDriverName } from './drivers/tasks-driver.constants';
import { z } from 'zod';
import { booleanishSchema } from '../config/config.schemas';
import { tasksDriverNames } from './drivers/tasks-driver.constants';
export const tasksConfig = {
persistence: {
driver: {
doc: 'The driver to use for the tasks persistence',
schema: z.enum(['memory']),
driverName: {
doc: `The driver to use for the tasks persistence, values can be one of: ${tasksDriverNames.map(x => `\`${x}\``).join(', ')}. Using the memory driver is enough when running a single instance of the server.`,
schema: z.enum(tasksDriverNames as [TasksDriverName, ...TasksDriverName[]]),
default: 'memory',
env: 'TASKS_PERSISTENCE_DRIVER',
},
drivers: {
libSql: {
url: {
doc: 'The URL of the LibSQL database, can be either a file-protocol url with a local path or a remote LibSQL database URL',
schema: z.string().url(),
default: 'file:./tasks-db.sqlite',
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_URL',
},
authToken: {
doc: 'The auth token for the LibSQL database',
schema: z.string().optional(),
default: undefined,
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_AUTH_TOKEN',
},
pollIntervalMs: {
doc: 'The interval at which the task persistence driver polls for new tasks',
schema: z.coerce.number().int().positive(),
default: 1_000,
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_POLL_INTERVAL_MS',
},
},
},
},
worker: {
id: {

View File

@@ -1,7 +1,9 @@
import type { Config } from '../config/config.types';
import { createCadence } from '@cadence-mq/core';
import { createMemoryDriver } from '@cadence-mq/driver-memory';
import { createError } from '../shared/errors/errors';
import { createLogger } from '../shared/logger/logger';
import { isNil } from '../shared/utils';
import { tasksDrivers } from './drivers/tasks-driver.registry';
export type TaskServices = ReturnType<typeof createTaskServices>;
@@ -9,12 +11,30 @@ const logger = createLogger({ namespace: 'tasks:services' });
export function createTaskServices({ config }: { config: Config }) {
const workerId = config.tasks.worker.id ?? 'default';
const taskPersistenceConfig = config.tasks.persistence;
const { driverName } = taskPersistenceConfig;
const driver = createMemoryDriver();
const driverFactory = tasksDrivers[driverName];
if (isNil(driverFactory)) {
// Should not happen as the config validation should catch invalid driver names
throw createError({
message: `Invalid task service driver: ${driverName}`,
code: 'tasks.invalid_driver',
statusCode: 500,
isInternal: true,
});
}
const { driver, initialize } = driverFactory({ taskPersistenceConfig });
const cadence = createCadence({ driver, logger });
return {
...cadence,
initialize: async () => {
await initialize?.();
logger.debug({ driverName }, 'Task persistence driver initialized');
},
start: () => {
const worker = cadence.createWorker({ workerId });

View File

@@ -0,0 +1,7 @@
import type { JobRepositoryDriver } from '@cadence-mq/core';
import type { Config } from '../config/config.types';
export type TaskPersistenceConfig = Config['tasks']['persistence'];
export type TaskServiceDriverDefinition = { driver: JobRepositoryDriver; initialize?: () => Promise<void> };
export type TaskServiceDriverFactory = (args: { taskPersistenceConfig: TaskPersistenceConfig }) => TaskServiceDriverDefinition;

View File

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

View File

@@ -15,8 +15,8 @@
"version": "changeset version && pnpm install --no-frozen-lockfile",
"changeset": "changeset",
"build:packages": "pnpm --filter './packages/*' --stream build",
"test": "TZ=UTC vitest run",
"test:watch": "TZ=UTC vitest watch"
"test": "vitest run",
"test:watch": "vitest watch"
},
"devDependencies": {
"@changesets/changelog-github": "^0.5.1",

View File

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

View File

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

View File

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

View File

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

156
pnpm-lock.yaml generated
View File

@@ -253,6 +253,9 @@ importers:
'@cadence-mq/core':
specifier: ^0.2.1
version: 0.2.1
'@cadence-mq/driver-libsql':
specifier: ^0.2.1
version: 0.2.1(@cadence-mq/core@0.2.1)(@libsql/client@0.14.0)
'@cadence-mq/driver-memory':
specifier: ^0.2.0
version: 0.2.0(@cadence-mq/core@0.2.1)
@@ -1019,6 +1022,11 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/parser@7.28.4':
resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/plugin-syntax-jsx@7.25.9':
resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==}
engines: {node: '>=6.9.0'}
@@ -1045,6 +1053,10 @@ packages:
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
engines: {node: '>=6.9.0'}
'@babel/types@7.28.4':
resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==}
engines: {node: '>=6.9.0'}
'@balena/dockerignore@1.0.2':
resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==}
@@ -1061,6 +1073,12 @@ packages:
'@cadence-mq/core@0.2.1':
resolution: {integrity: sha512-Cu/jqR7mNhMZ1U4Boiudy2nePyf4PtqBUFGhUcsCQPJfymKcrDm4xjp8A/2tKZr5JSgkN/7L0/+mHZ27GVSryQ==}
'@cadence-mq/driver-libsql@0.2.1':
resolution: {integrity: sha512-tQPmMNLLVEhvT2HdY/rHk+Cl0Yj4JFMQnoYnBYIw30kTIpKGCQWnBTf5oSmIlmc6wdIHYan+f+waVhWmkObD1w==}
peerDependencies:
'@cadence-mq/core': ^0.2.0
'@libsql/client': ^0.15.9
'@cadence-mq/driver-memory@0.2.0':
resolution: {integrity: sha512-U/L5nkCu+BYO814oQAYbFYSNASha+6Om3Px3+Jm47YzFmSrQhrnX6fTyICVQFz+MflWndhke6Bh1mwik6nbrcw==}
peerDependencies:
@@ -1176,6 +1194,10 @@ packages:
resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==}
engines: {node: '>=18'}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'}
'@csstools/css-calc@2.1.3':
resolution: {integrity: sha512-XBG3talrhid44BY1x3MHzUx/aTG8+x/Zi57M4aTKK9RFB4aLlF3TTSzfzn8nWVHWL3FgAXAxmupmDd6VWww+pw==}
engines: {node: '>=18'}
@@ -1190,13 +1212,6 @@ packages:
'@csstools/css-parser-algorithms': ^3.0.5
'@csstools/css-tokenizer': ^3.0.4
'@csstools/css-color-parser@3.0.10':
resolution: {integrity: sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==}
engines: {node: '>=18'}
peerDependencies:
'@csstools/css-parser-algorithms': ^3.0.5
'@csstools/css-tokenizer': ^3.0.4
'@csstools/css-color-parser@3.0.9':
resolution: {integrity: sha512-wILs5Zk7BU86UArYBJTPy/FMPPKVKHMj1ycCEyf3VUptol0JNRLFU/BZsJ4aiIHJEbSLiizzRrw8Pc1uAEDrXw==}
engines: {node: '>=18'}
@@ -1204,6 +1219,13 @@ packages:
'@csstools/css-parser-algorithms': ^3.0.4
'@csstools/css-tokenizer': ^3.0.3
'@csstools/css-color-parser@3.1.0':
resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
engines: {node: '>=18'}
peerDependencies:
'@csstools/css-parser-algorithms': ^3.0.5
'@csstools/css-tokenizer': ^3.0.4
'@csstools/css-parser-algorithms@3.0.4':
resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==}
engines: {node: '>=18'}
@@ -2440,6 +2462,9 @@ packages:
'@jridgewell/sourcemap-codec@1.5.4':
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
@@ -3981,8 +4006,8 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
aproba@2.0.0:
resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==}
aproba@2.1.0:
resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==}
archiver-utils@5.0.2:
resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
@@ -4542,9 +4567,21 @@ packages:
supports-color:
optional: true
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
decimal.js@10.5.0:
resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
decode-named-character-reference@1.0.2:
resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==}
@@ -4606,6 +4643,10 @@ packages:
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
engines: {node: '>=8'}
detect-libc@2.0.4:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
deterministic-object-hash@2.0.2:
resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==}
engines: {node: '>=18'}
@@ -5422,8 +5463,8 @@ packages:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
engines: {node: '>= 6'}
form-data@4.0.3:
resolution: {integrity: sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==}
form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
format@0.2.2:
@@ -6108,6 +6149,9 @@ packages:
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
@@ -6432,6 +6476,9 @@ packages:
nan@2.22.0:
resolution: {integrity: sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==}
nan@2.23.0:
resolution: {integrity: sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -6527,6 +6574,9 @@ packages:
nwsapi@2.2.20:
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==}
nwsapi@2.2.22:
resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==}
nypm@0.6.0:
resolution: {integrity: sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg==}
engines: {node: ^14.16.0 || >=16.10.0}
@@ -8573,7 +8623,7 @@ snapshots:
'@asamuzakjp/css-color@3.2.0':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-color-parser': 3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
lru-cache: 10.4.3
@@ -9390,6 +9440,10 @@ snapshots:
dependencies:
'@babel/types': 7.28.0
'@babel/parser@7.28.4':
dependencies:
'@babel/types': 7.28.4
'@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.0)':
dependencies:
'@babel/core': 7.26.0
@@ -9427,6 +9481,11 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@babel/types@7.28.4':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@balena/dockerignore@1.0.2': {}
'@bcoe/v8-coverage@1.0.2': {}
@@ -9445,6 +9504,11 @@ snapshots:
'@standard-schema/spec': 1.0.0
cron-parser: 5.3.0
'@cadence-mq/driver-libsql@0.2.1(@cadence-mq/core@0.2.1)(@libsql/client@0.14.0)':
dependencies:
'@cadence-mq/core': 0.2.1
'@libsql/client': 0.14.0
'@cadence-mq/driver-memory@0.2.0(@cadence-mq/core@0.2.1)':
dependencies:
'@cadence-mq/core': 0.2.1
@@ -9666,6 +9730,9 @@ snapshots:
'@csstools/color-helpers@5.0.2': {}
'@csstools/color-helpers@5.1.0':
optional: true
'@csstools/css-calc@2.1.3(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)':
dependencies:
'@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3)
@@ -9677,14 +9744,6 @@ snapshots:
'@csstools/css-tokenizer': 3.0.4
optional: true
'@csstools/css-color-parser@3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
dependencies:
'@csstools/color-helpers': 5.0.2
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
optional: true
'@csstools/css-color-parser@3.0.9(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)':
dependencies:
'@csstools/color-helpers': 5.0.2
@@ -9692,6 +9751,14 @@ snapshots:
'@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3)
'@csstools/css-tokenizer': 3.0.3
'@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
dependencies:
'@csstools/color-helpers': 5.1.0
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
optional: true
'@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)':
dependencies:
'@csstools/css-tokenizer': 3.0.3
@@ -10536,6 +10603,8 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.4': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
@@ -10672,7 +10741,7 @@ snapshots:
'@mapbox/node-pre-gyp@1.0.11':
dependencies:
detect-libc: 2.0.3
detect-libc: 2.0.4
https-proxy-agent: 5.0.1
make-dir: 3.1.0
node-fetch: 2.7.0
@@ -12348,7 +12417,7 @@ snapshots:
'@vue/compiler-core@3.5.13':
dependencies:
'@babel/parser': 7.28.0
'@babel/parser': 7.28.4
'@vue/shared': 3.5.13
entities: 4.5.0
estree-walker: 2.0.2
@@ -12361,13 +12430,13 @@ snapshots:
'@vue/compiler-sfc@3.5.13':
dependencies:
'@babel/parser': 7.28.0
'@babel/parser': 7.28.4
'@vue/compiler-core': 3.5.13
'@vue/compiler-dom': 3.5.13
'@vue/compiler-ssr': 3.5.13
'@vue/shared': 3.5.13
estree-walker: 2.0.2
magic-string: 0.30.17
magic-string: 0.30.19
postcss: 8.5.6
source-map-js: 1.2.1
@@ -12421,7 +12490,7 @@ snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.4.1
debug: 4.4.3
transitivePeerDependencies:
- supports-color
optional: true
@@ -12469,7 +12538,7 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
aproba@2.0.0:
aproba@2.1.0:
optional: true
archiver-utils@5.0.2:
@@ -12914,7 +12983,7 @@ snapshots:
canvas@2.11.2:
dependencies:
'@mapbox/node-pre-gyp': 1.0.11
nan: 2.22.0
nan: 2.23.0
simple-get: 3.1.1
transitivePeerDependencies:
- encoding
@@ -13167,8 +13236,16 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.4.3:
dependencies:
ms: 2.1.3
optional: true
decimal.js@10.5.0: {}
decimal.js@10.6.0:
optional: true
decode-named-character-reference@1.0.2:
dependencies:
character-entities: 2.0.2
@@ -13213,6 +13290,9 @@ snapshots:
detect-libc@2.0.3: {}
detect-libc@2.0.4:
optional: true
deterministic-object-hash@2.0.2:
dependencies:
base-64: 1.0.0
@@ -14324,7 +14404,7 @@ snapshots:
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
form-data@4.0.3:
form-data@4.0.4:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
@@ -14371,7 +14451,7 @@ snapshots:
gauge@3.0.2:
dependencies:
aproba: 2.0.0
aproba: 2.1.0
color-support: 1.1.3
console-control-strings: 1.1.0
has-unicode: 2.0.1
@@ -14763,7 +14843,7 @@ snapshots:
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.1
debug: 4.4.3
transitivePeerDependencies:
- supports-color
optional: true
@@ -15003,13 +15083,13 @@ snapshots:
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.5.0
form-data: 4.0.3
decimal.js: 10.6.0
form-data: 4.0.4
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.20
nwsapi: 2.2.22
parse5: 7.3.0
rrweb-cssom: 0.8.0
saxes: 6.0.0
@@ -15152,6 +15232,10 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
magic-string@0.30.19:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.3.5:
dependencies:
'@babel/parser': 7.28.0
@@ -15800,6 +15884,9 @@ snapshots:
nan@2.22.0:
optional: true
nan@2.23.0:
optional: true
nanoid@3.3.11: {}
nanoid@5.1.5: {}
@@ -15876,6 +15963,9 @@ snapshots:
nwsapi@2.2.20: {}
nwsapi@2.2.22:
optional: true
nypm@0.6.0:
dependencies:
citty: 0.1.6

View File

@@ -1,12 +1,14 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
reporters: ['verbose'],
projects: ['apps/*', 'packages/*'],
coverage: {
include: ['packages/*/src'],
}
},
env: {
TZ: 'UTC',
},
},
})