Compare commits

...

5 Commits

Author SHA1 Message Date
github-actions[bot]
c0c60c965e chore(release): update versions 2025-12-29 11:23:16 +00:00
Corentin Thomasset
b8c14d0f44 feat(i18n): add timeout error message in all locales (#712) 2025-12-29 11:22:28 +00:00
Corentin Thomasset
4878a3f8dd refactor(config): replace hardcoded time and size values with constants (#708) 2025-12-28 14:36:20 +01:00
Corentin Thomasset
a213f0683b feat(timeout): implement route-specific timeout configuration for uploads (#707) 2025-12-28 02:35:32 +01:00
Corentin Thomasset
6a5bcef5ad fix(subscription): proper file size limit enforcing 2025-12-27 03:48:34 +01:00
23 changed files with 335 additions and 20 deletions

View File

@@ -1,5 +0,0 @@
---
"@papra/docker": patch
---
Organizations listing and details in the admin dashboard

View File

@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.api.timeout': 'Die Anfrage hat zu lange gedauert und ist abgelaufen. Bitte versuchen Sie es erneut.',
'api-errors.document.already_exists': 'Das Dokument existiert bereits',
'api-errors.document.size_too_large': 'Die Datei ist zu groß',
'api-errors.intake-emails.already_exists': 'Eine Eingang-Email mit dieser Adresse existiert bereits.',

View File

@@ -598,6 +598,7 @@ export const translations = {
// API errors
'api-errors.api.timeout': 'The request took too long and timed out. Please try again.',
'api-errors.document.already_exists': 'The document already exists',
'api-errors.document.size_too_large': 'The file size is too large',
'api-errors.intake-emails.already_exists': 'An intake email with this address already exists.',

View File

@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.api.timeout': 'La solicitud tardó demasiado y se agotó el tiempo. Por favor, inténtalo de nuevo.',
'api-errors.document.already_exists': 'El documento ya existe',
'api-errors.document.size_too_large': 'El archivo es demasiado grande',
'api-errors.intake-emails.already_exists': 'Ya existe un correo de ingreso con esta dirección.',

View File

@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.api.timeout': 'La requête a pris trop de temps et a expiré. Veuillez réessayer.',
'api-errors.document.already_exists': 'Le document existe déjà',
'api-errors.document.size_too_large': 'Le fichier est trop volumineux',
'api-errors.intake-emails.already_exists': 'Un email de réception avec cette adresse existe déjà.',

View File

@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.api.timeout': 'La richiesta ha impiegato troppo tempo ed è scaduta. Riprova.',
'api-errors.document.already_exists': 'Il documento esiste già',
'api-errors.document.size_too_large': 'Il file è troppo grande',
'api-errors.intake-emails.already_exists': 'Un\'email di acquisizione con questo indirizzo esiste già.',

View File

@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.api.timeout': 'Het verzoek duurde te lang en is verlopen. Probeer het opnieuw.',
'api-errors.document.already_exists': 'Het document bestaat al',
'api-errors.document.size_too_large': 'Het bestand is te groot',
'api-errors.intake-emails.already_exists': 'Er bestaat al een intake-e-mail met dit adres.',

View File

@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.api.timeout': 'Żądanie trwało zbyt długo i przekroczyło limit czasu. Spróbuj ponownie.',
'api-errors.document.already_exists': 'Dokument już istnieje',
'api-errors.document.size_too_large': 'Plik jest zbyt duży',
'api-errors.intake-emails.already_exists': 'Adres e-mail do przyjęć z tym adresem już istnieje.',

View File

@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.api.timeout': 'A solicitação demorou muito e expirou. Por favor, tente novamente.',
'api-errors.document.already_exists': 'O documento já existe',
'api-errors.document.size_too_large': 'O arquivo é muito grande',
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',

View File

@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.api.timeout': 'O pedido demorou muito tempo e expirou. Por favor, tente novamente.',
'api-errors.document.already_exists': 'O documento já existe',
'api-errors.document.size_too_large': 'O arquivo é muito grande',
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',

View File

@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.api.timeout': 'Cererea a durat prea mult și a expirat. Vă rugăm să încercați din nou.',
'api-errors.document.already_exists': 'Documentul există deja',
'api-errors.document.size_too_large': 'Fișierul este prea mare',
'api-errors.intake-emails.already_exists': 'Un email de primire cu această adresă există deja.',

View File

@@ -600,6 +600,7 @@ export const translations: Partial<TranslationsDictionary> = {
// API errors
'api-errors.api.timeout': '请求耗时过长已超时。请重试。',
'api-errors.document.already_exists': '文档已存在',
'api-errors.document.size_too_large': '文件大小过大',
'api-errors.intake-emails.already_exists': '具有此地址的接收邮箱已存在。',

View File

@@ -8,7 +8,7 @@ import { createTimeoutMiddleware } from './timeout.middleware';
describe('middlewares', () => {
describe('timeoutMiddleware', () => {
test('when a request last longer than the config timeout, a 504 error is raised', async () => {
const config = overrideConfig({ server: { routeTimeoutMs: 50 } });
const config = overrideConfig({ server: { defaultRouteTimeoutMs: 50 } });
const app = new Hono<ServerInstanceGenerics>();
registerErrorMiddleware({ app });
@@ -45,5 +45,107 @@ describe('middlewares', () => {
expect(response2.status).to.eql(200);
expect(await response2.json()).to.eql({ status: 'ok' });
});
test('route-specific timeout overrides default timeout for matching routes', async () => {
const config = overrideConfig({
server: {
defaultRouteTimeoutMs: 50,
routeTimeouts: [
{
method: 'POST',
route: '/api/upload/:id',
timeoutMs: 200,
},
],
},
});
const app = new Hono<ServerInstanceGenerics>();
registerErrorMiddleware({ app });
// POST to matching route with longer timeout - should not timeout
app.post(
'/api/upload/:id',
createTimeoutMiddleware({ config }),
async (context) => {
await new Promise(resolve => setTimeout(resolve, 100));
return context.json({ status: 'ok' });
},
);
// GET to same route - should timeout with default
app.get(
'/api/upload/:id',
createTimeoutMiddleware({ config }),
async (context) => {
await new Promise(resolve => setTimeout(resolve, 100));
return context.json({ status: 'ok' });
},
);
// Different route - should timeout with default
app.post(
'/api/other',
createTimeoutMiddleware({ config }),
async (context) => {
await new Promise(resolve => setTimeout(resolve, 100));
return context.json({ status: 'ok' });
},
);
// POST to matching pattern should succeed
const response1 = await app.request('/api/upload/123', { method: 'POST' });
expect(response1.status).to.eql(200);
// GET to same path should timeout (method mismatch)
const response2 = await app.request('/api/upload/123', { method: 'GET' });
expect(response2.status).to.eql(504);
// POST to different path should timeout (path mismatch)
const response3 = await app.request('/api/other', { method: 'POST' });
expect(response3.status).to.eql(504);
});
test('when registered globally with .use(), route-specific timeouts should work', async () => {
const config = overrideConfig({
server: {
defaultRouteTimeoutMs: 50,
routeTimeouts: [
{
method: 'POST',
route: '/api/organizations/:orgId/documents',
timeoutMs: 200,
},
],
},
});
const app = new Hono<ServerInstanceGenerics>();
registerErrorMiddleware({ app });
// Register middleware globally (like in server.ts)
app.use(createTimeoutMiddleware({ config }));
// Route that should have extended timeout
app.post('/api/organizations/:orgId/documents', async (context) => {
await new Promise(resolve => setTimeout(resolve, 100));
return context.json({ status: 'upload ok' });
});
// Route that should use default timeout
app.get('/api/other', async (context) => {
await new Promise(resolve => setTimeout(resolve, 100));
return context.json({ status: 'ok' });
});
// POST to upload route should succeed (extended timeout)
const response1 = await app.request('/api/organizations/org-123/documents', { method: 'POST' });
expect(response1.status).to.eql(200);
expect(await response1.json()).to.eql({ status: 'upload ok' });
// GET to other route should timeout (default timeout)
const response2 = await app.request('/api/other', { method: 'GET' });
expect(response2.status).to.eql(504);
});
});
});

View File

@@ -1,11 +1,42 @@
import type { Config } from '../../config/config.types';
import type { Context } from '../server.types';
import { createMiddleware } from 'hono/factory';
import { routePath } from 'hono/route';
import { createError } from '../../shared/errors/errors';
function getTimeoutForRoute({
defaultRouteTimeoutMs,
routeTimeouts,
method,
path,
}: {
defaultRouteTimeoutMs: number;
routeTimeouts: { method: string; route: string; timeoutMs: number }[];
method: string;
path: string;
}): number {
const matchingRoute = routeTimeouts.find((routeConfig) => {
if (routeConfig.method !== method) {
return false;
}
if (routeConfig.route !== path) {
return false;
}
return true;
});
return matchingRoute?.timeoutMs ?? defaultRouteTimeoutMs;
}
export function createTimeoutMiddleware({ config }: { config: Config }) {
return createMiddleware(async (context: Context, next) => {
const { server: { routeTimeoutMs } } = config;
const method = context.req.method;
const path = routePath(context, -1); // Get the last matched route path, without the -1 we get /* for all routes
const { defaultRouteTimeoutMs, routeTimeouts } = config.server;
const timeoutMs = getTimeoutForRoute({ defaultRouteTimeoutMs, routeTimeouts, method, path });
let timerId: NodeJS.Timeout | undefined;
@@ -16,7 +47,7 @@ export function createTimeoutMiddleware({ config }: { config: Config }) {
message: 'The request timed out',
statusCode: 504,
}),
), routeTimeoutMs);
), timeoutMs);
});
try {

View File

@@ -16,6 +16,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 { IN_MS } from '../shared/units';
import { isString } from '../shared/utils';
import { subscriptionsConfig } from '../subscriptions/subscriptions.config';
import { tasksConfig } from '../tasks/tasks.config';
@@ -84,12 +85,29 @@ export const configDefinition = {
default: '0.0.0.0',
env: 'SERVER_HOSTNAME',
},
routeTimeoutMs: {
defaultRouteTimeoutMs: {
doc: 'The maximum time in milliseconds for a route to complete before timing out',
schema: z.coerce.number().int().positive(),
default: 20_000,
default: 20 * IN_MS.SECOND,
env: 'SERVER_API_ROUTES_TIMEOUT_MS',
},
routeTimeouts: {
doc: 'Route-specific timeout overrides. Allows setting different timeouts for specific HTTP method and route paths.',
schema: z.array(
z.object({
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']),
route: z.string(),
timeoutMs: z.number().int().positive(),
}),
),
default: [
{
method: 'POST',
route: '/api/organizations/:organizationId/documents',
timeoutMs: 5 * IN_MS.MINUTE,
},
],
},
corsOrigins: {
doc: 'The CORS origin for the api server',
schema: z.union([

View File

@@ -6,8 +6,11 @@ import { getUser } from '../app/auth/auth.models';
import { organizationIdSchema } from '../organizations/organization.schemas';
import { createOrganizationsRepository } from '../organizations/organizations.repository';
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
import { createPlansRepository } from '../plans/plans.repository';
import { getOrganizationPlan } from '../plans/plans.usecases';
import { getFileStreamFromMultipartForm } from '../shared/streams/file-upload';
import { validateJsonBody, validateParams, validateQuery } from '../shared/validation/validation';
import { createSubscriptionsRepository } from '../subscriptions/subscriptions.repository';
import { createDocumentIsNotDeletedError } from './documents.errors';
import { formatDocumentForApi, formatDocumentsForApi, isDocumentSizeLimitEnabled } from './documents.models';
import { createDocumentsRepository } from './documents.repository';
@@ -45,12 +48,17 @@ function setupCreateDocumentRoute({ app, ...deps }: RouteDefinitionContext) {
const organizationsRepository = createOrganizationsRepository({ db });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
const { maxUploadSize } = config.documentsStorage;
// Get organization's plan-specific upload limit
const plansRepository = createPlansRepository({ config });
const subscriptionsRepository = createSubscriptionsRepository({ db });
const { organizationPlan } = await getOrganizationPlan({ organizationId, plansRepository, subscriptionsRepository });
const { maxFileSize } = organizationPlan.limits;
const { fileStream, fileName, mimeType } = await getFileStreamFromMultipartForm({
body: context.req.raw.body,
headers: context.req.header(),
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : undefined,
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize: maxFileSize }) ? maxFileSize : undefined,
});
const createDocument = createDocumentCreationUsecase({ ...deps });

View File

@@ -5,6 +5,7 @@ import { createServer } from '../../app/server';
import { createTestServerDependencies } from '../../app/server.test-utils';
import { overrideConfig } from '../../config/config.test-utils';
import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants';
import { PLUS_PLAN_ID, PRO_PLAN_ID } from '../../plans/plans.constants';
import { documentsTable } from '../documents.table';
import { inMemoryStorageDriverFactory } from '../storage/drivers/memory/memory.storage-driver';
@@ -247,5 +248,123 @@ describe('documents e2e', () => {
expect(retrievedDocument).to.eql({ ...document, tags: [] });
}
});
test('organizations on Plus plan should be able to upload files up to 100 MiB (not limited by global config)', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
organizations: [{ id: 'org_222222222222222222222222', name: 'Plus Org', customerId: 'cus_plus123' }],
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
organizationSubscriptions: [{
id: 'sub_plus123',
customerId: 'cus_plus123',
organizationId: 'org_222222222222222222222222',
planId: PLUS_PLAN_ID,
status: 'active',
seatsCount: 5,
currentPeriodStart: new Date('2024-01-01'),
currentPeriodEnd: new Date('2024-02-01'),
cancelAtPeriodEnd: false,
}],
});
const { app } = createServer(createTestServerDependencies({
db,
config: overrideConfig({
env: 'test',
documentsStorage: {
driver: 'in-memory',
// Global config set to 10 MiB (simulating free tier limit)
maxUploadSize: 1024 * 1024 * 10, // 10 MiB
},
}),
}));
// File size: 50 MiB - exceeds global config (10 MiB) but within Plus plan limit (100 MiB)
const fileSizeBytes = 1024 * 1024 * 50; // 50 MiB
const formData = new FormData();
formData.append('file', new File(['a'.repeat(fileSizeBytes)], 'large-document.txt', { 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' },
);
// Should succeed because Plus plan allows 100 MiB
expect(createDocumentResponse.status).to.eql(200);
const { document } = (await createDocumentResponse.json()) as { document: Document };
expect(document).to.include({
name: 'large-document.txt',
mimeType: 'text/plain',
originalSize: fileSizeBytes,
});
});
test('organizations on Pro plan should be able to upload files up to 500 MiB (not limited by global config)', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
organizations: [{ id: 'org_333333333333333333333333', name: 'Pro Org', customerId: 'cus_pro123' }],
organizationMembers: [{ organizationId: 'org_333333333333333333333333', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
organizationSubscriptions: [{
id: 'sub_pro123',
customerId: 'cus_pro123',
organizationId: 'org_333333333333333333333333',
planId: PRO_PLAN_ID,
status: 'active',
seatsCount: 20,
currentPeriodStart: new Date('2024-01-01'),
currentPeriodEnd: new Date('2024-02-01'),
cancelAtPeriodEnd: false,
}],
});
const { app } = createServer(createTestServerDependencies({
db,
config: overrideConfig({
env: 'test',
documentsStorage: {
driver: 'in-memory',
// Global config set to 10 MiB (simulating free tier limit)
maxUploadSize: 1024 * 1024 * 10, // 10 MiB
},
}),
}));
// File size: 200 MiB - exceeds global config (10 MiB) but within Pro plan limit (500 MiB)
const fileSizeBytes = 1024 * 1024 * 200; // 200 MiB
const formData = new FormData();
formData.append('file', new File(['a'.repeat(fileSizeBytes)], 'very-large-document.txt', { type: 'text/plain' }));
const body = new Response(formData);
const createDocumentResponse = await app.request(
'/api/organizations/org_333333333333333333333333/documents',
{
method: 'POST',
headers: {
...Object.fromEntries(body.headers.entries()),
},
body: await body.arrayBuffer(),
},
{ loggedInUserId: 'usr_111111111111111111111111' },
);
// Should succeed because Pro plan allows 500 MiB
expect(createDocumentResponse.status).to.eql(200);
const { document } = (await createDocumentResponse.json()) as { document: Document };
expect(document).to.include({
name: 'very-large-document.txt',
mimeType: 'text/plain',
originalSize: fileSizeBytes,
});
});
});
});

View File

@@ -1,6 +1,7 @@
import type { ConfigDefinition } from 'figue';
import { z } from 'zod';
import { booleanishSchema } from '../config/config.schemas';
import { IN_MS } from '../shared/units';
import { isString } from '../shared/utils';
import { defaultIgnoredPatterns } from './ingestion-folders.constants';
@@ -27,7 +28,7 @@ export const ingestionFolderConfig = {
pollingInterval: {
doc: 'When polling is used, this is the interval at which the watcher checks for changes in the ingestion folder (in milliseconds)',
schema: z.coerce.number().int().positive(),
default: 2_000,
default: 2 * IN_MS.SECOND,
env: 'INGESTION_FOLDER_WATCHER_POLLING_INTERVAL_MS',
},
},

View File

@@ -2,6 +2,7 @@ import type { Config } from '../config/config.types';
import type { OrganizationPlanRecord } from './plans.types';
import { injectArguments } from '@corentinth/chisels';
import { isDocumentSizeLimitEnabled } from '../documents/documents.models';
import { IN_BYTES } from '../shared/units';
import { FREE_PLAN_ID, PLUS_PLAN_ID, PRO_PLAN_ID } from './plans.constants';
import { createPlanNotFoundError } from './plans.errors';
@@ -30,7 +31,7 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
id: FREE_PLAN_ID,
name: 'Free',
limits: {
maxDocumentStorageBytes: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1024 * 1024 * 500, // 500 MiB
maxDocumentStorageBytes: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 500 * IN_BYTES.MEGABYTE,
maxIntakeEmailsCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 1,
maxOrganizationsMembersCount: isFreePlanUnlimited ? Number.POSITIVE_INFINITY : 3,
maxFileSize: isDocumentSizeLimitEnabled({ maxUploadSize }) ? maxUploadSize : Number.POSITIVE_INFINITY,
@@ -42,10 +43,10 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
monthlyPriceId: config.organizationPlans.plusPlanMonthlyPriceId,
annualPriceId: config.organizationPlans.plusPlanAnnualPriceId,
limits: {
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 5, // 5 GiB
maxDocumentStorageBytes: 5 * IN_BYTES.GIGABYTE, // 5 GiB
maxIntakeEmailsCount: 10,
maxOrganizationsMembersCount: 10,
maxFileSize: 1024 * 1024 * 100, // 100 MiB
maxFileSize: 100 * IN_BYTES.MEGABYTE, // 100 MiB
},
},
[PRO_PLAN_ID]: {
@@ -54,10 +55,10 @@ export function getOrganizationPlansRecords({ config }: { config: Config }) {
monthlyPriceId: config.organizationPlans.proPlanMonthlyPriceId,
annualPriceId: config.organizationPlans.proPlanAnnualPriceId,
limits: {
maxDocumentStorageBytes: 1024 * 1024 * 1024 * 50, // 50 GiB
maxDocumentStorageBytes: 50 * IN_BYTES.GIGABYTE, // 50 GiB
maxIntakeEmailsCount: 100,
maxOrganizationsMembersCount: 50,
maxFileSize: 1024 * 1024 * 500, // 500 MiB
maxFileSize: 500 * IN_BYTES.MEGABYTE, // 500 MiB
},
},
};

View File

@@ -0,0 +1,16 @@
export const IN_MS = {
SECOND: 1_000,
MINUTE: 60_000, // 60 * 1_000
HOUR: 3_600_000, // 60 * 60 * 1_000
DAY: 86_400_000, // 24 * 60 * 60 * 1_000
WEEK: 604_800_000, // 7 * 24 * 60 * 60 * 1_000
MONTH: 2_630_016_000, // 30.44 * 24 * 60 * 60 * 1_000 -- approximation using average month length
YEAR: 31_556_736_000, // 365.24 * 24 * 60 * 60 * 1_000 -- approximation using average year length
};
export const IN_BYTES = {
KILOBYTE: 1_024,
MEGABYTE: 1_048_576, // 1_024 * 1_024
GIGABYTE: 1_073_741_824, // 1_024 * 1_024 * 1_024
TERABYTE: 1_099_511_627_776, // 1_024 * 1_024 * 1_024 * 1_024
};

View File

@@ -2,6 +2,7 @@ import type { ConfigDefinition } from 'figue';
import type { TasksDriverName } from './drivers/tasks-driver.constants';
import { z } from 'zod';
import { booleanishSchema } from '../config/config.schemas';
import { IN_MS } from '../shared/units';
import { tasksDriverNames } from './drivers/tasks-driver.constants';
export const tasksConfig = {
@@ -35,7 +36,7 @@ export const tasksConfig = {
pollIntervalMs: {
doc: 'The interval at which the task persistence driver polls for new tasks',
schema: z.coerce.number().int().positive(),
default: 1_000,
default: 1 * IN_MS.SECOND,
env: 'TASKS_PERSISTENCE_DRIVERS_LIBSQL_POLL_INTERVAL_MS',
},
},

View File

@@ -1,5 +1,17 @@
# @papra/docker
## 25.12.1
### Patch Changes
- [#707](https://github.com/papra-hq/papra/pull/707) [`a213f06`](https://github.com/papra-hq/papra/commit/a213f0683baebd6546bf38ba9e719c31b60064ed) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a dedicated increased timeout for the document upload route
- [#712](https://github.com/papra-hq/papra/pull/712) [`b8c14d0`](https://github.com/papra-hq/papra/commit/b8c14d0f44628843c7a682f84f7215fecc50f426) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a feedback message upon request timeout
- [#702](https://github.com/papra-hq/papra/pull/702) [`ec34cf1`](https://github.com/papra-hq/papra/commit/ec34cf17880682369d1ecf2957c2d7e0eed9f499) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Organizations listing and details in the admin dashboard
- [#707](https://github.com/papra-hq/papra/pull/707) [`a213f06`](https://github.com/papra-hq/papra/commit/a213f0683baebd6546bf38ba9e719c31b60064ed) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Changed config key `config.server.routeTimeoutMs` to `config.server.defaultRouteTimeoutMs` (env variable remains the same)
## 25.12.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@papra/docker",
"version": "25.12.0",
"version": "25.12.1",
"private": true,
"description": "Docker image version tracker for Papra, calver-ish versioned.",
"repository": {