fix(upload): use organization-specific file size limits (#555)

This commit is contained in:
Corentin Thomasset
2025-10-14 03:09:54 +02:00
committed by GitHub
parent bb1ba3e15e
commit c5b337f3bb
13 changed files with 57 additions and 35 deletions

View File

@@ -0,0 +1,5 @@
---
"@papra/docker": patch
---
Use organization max file size limit for pre-upload validation

View File

@@ -40,10 +40,7 @@ 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;
export type RuntimePublicConfig = Pick<Config, 'auth' | 'documents' | 'documentsStorage' | 'intakeEmails' | 'organizations'>;
export type RuntimePublicConfig = Pick<Config, 'auth' | 'documents' | 'intakeEmails' | 'organizations'>;

View File

@@ -2,23 +2,24 @@ import type { ParentComponent } from 'solid-js';
import type { Document } from '../documents.types';
import { safely } from '@corentinth/chisels';
import { A } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
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';
import { cn } from '@/modules/shared/style/cn';
import { throttle } from '@/modules/shared/utils/timing';
import { fetchOrganizationSubscription } from '@/modules/subscriptions/subscriptions.services';
import { Button } from '@/modules/ui/components/button';
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
import { uploadDocument } from '../documents.services';
const DocumentUploadContext = createContext<{
uploadDocuments: (args: { files: File[]; organizationId: string }) => Promise<void>;
uploadDocuments: (args: { files: File[] }) => Promise<void>;
}>();
export function useDocumentUpload({ getOrganizationId }: { getOrganizationId: () => string }) {
export function useDocumentUpload() {
const context = useContext(DocumentUploadContext);
if (!context) {
@@ -28,11 +29,11 @@ export function useDocumentUpload({ getOrganizationId }: { getOrganizationId: ()
const { uploadDocuments } = context;
return {
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId: getOrganizationId() }),
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files }),
promptImport: async () => {
const { files } = await promptUploadFiles();
await uploadDocuments({ files, organizationId: getOrganizationId() });
await uploadDocuments({ files });
},
};
}
@@ -54,11 +55,10 @@ type Task = TaskSuccess | TaskError | {
status: 'pending' | 'uploading';
};
export const DocumentUploadProvider: ParentComponent = (props) => {
export const DocumentUploadProvider: ParentComponent<{ organizationId: string }> = (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[]>([]);
@@ -67,20 +67,33 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
setTasks(tasks => tasks.map(task => task.file === args.file ? { ...task, ...args } : task));
};
const uploadDocuments = async ({ files, organizationId }: { files: File[]; organizationId: string }) => {
const organizationLimitsQuery = useQuery(() => ({
queryKey: ['organizations', props.organizationId, 'subscription'],
queryFn: () => fetchOrganizationSubscription({ organizationId: props.organizationId }),
refetchOnWindowFocus: false,
}));
const uploadDocuments = async ({ files }: { files: File[] }) => {
setTasks(tasks => [...tasks, ...files.map(file => ({ file, status: 'pending' } as const))]);
setState('open');
if (!organizationLimitsQuery.data) {
await organizationLimitsQuery.promise;
}
// Optimistic prevent upload if file is too large, the server will still validate it
const maxUploadSize = organizationLimitsQuery.data?.plan.limits.maxFileSize;
await Promise.all(files.map(async (file) => {
const { maxUploadSize } = config.documentsStorage;
updateTaskStatus({ file, status: 'uploading' });
if (maxUploadSize > 0 && file.size > maxUploadSize) {
// maxUploadSize can also be null when self hosting which means no limit
if (maxUploadSize && file.size > maxUploadSize) {
updateTaskStatus({ file, status: 'error', error: Object.assign(new Error('File too large'), { code: 'document.size_too_large' }) });
return;
}
const [result, error] = await safely(uploadDocument({ file, organizationId }));
const [result, error] = await safely(uploadDocument({ file, organizationId: props.organizationId }));
if (error) {
updateTaskStatus({ file, status: 'error', error });
@@ -90,7 +103,7 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
updateTaskStatus({ file, status: 'success', document });
}
throttledInvalidateOrganizationDocumentsQuery({ organizationId });
throttledInvalidateOrganizationDocumentsQuery({ organizationId: props.organizationId });
}));
};

View File

@@ -1,17 +1,13 @@
import type { Component } from 'solid-js';
import { useParams } from '@solidjs/router';
import { createSignal } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { useDocumentUpload } from './document-import-status.component';
export const DocumentUploadArea: Component<{ organizationId?: string }> = (props) => {
export const DocumentUploadArea: Component = () => {
const [isDragging, setIsDragging] = createSignal(false);
const params = useParams();
const getOrganizationId = () => props.organizationId ?? params.organizationId;
const { promptImport, uploadDocuments } = useDocumentUpload({ getOrganizationId });
const { promptImport, uploadDocuments } = useDocumentUpload();
const handleDragOver = (event: DragEvent) => {
event.preventDefault();

View File

@@ -29,7 +29,7 @@ export const OrganizationPage: Component = () => {
queryFn: () => getOrganizationDocumentsStats({ organizationId: params.organizationId }),
}));
const { promptImport } = useDocumentUpload({ getOrganizationId: () => params.organizationId });
const { promptImport } = useDocumentUpload();
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">

View File

@@ -0,0 +1,6 @@
export type PlanLimits = {
maxDocumentStorageBytes: number | null;
maxIntakeEmailsCount: number | null;
maxOrganizationsMembersCount: number | null;
maxFileSize: number | null;
};

View File

@@ -24,11 +24,11 @@ export const UsageWarningCard: Component<{ organizationId: string }> = (props) =
const getStorageSizeUsedPercent = () => {
const { data: usageData } = query;
if (!usageData || usageData.limits.maxDocumentsSize === null) {
if (!usageData || usageData.usage.documentsStorage.limit === null) {
return 0;
}
return (usageData.usage.documentsStorage.used / usageData.limits.maxDocumentsSize) * 100;
return (usageData.usage.documentsStorage.used / usageData.usage.documentsStorage.limit) * 100;
};
const shouldShow = () => {

View File

@@ -1,3 +1,4 @@
import type { PlanLimits } from '../plans/plans.types';
import type { OrganizationSubscription } from './subscriptions.types';
import { apiClient } from '../shared/http/api-client';
@@ -24,7 +25,14 @@ export async function getCustomerPortalUrl({ organizationId }: { organizationId:
}
export async function fetchOrganizationSubscription({ organizationId }: { organizationId: string }) {
const { subscription, plan } = await apiClient<{ subscription: OrganizationSubscription; plan: { id: string; name: string } }>({
const { subscription, plan } = await apiClient<{
subscription: OrganizationSubscription;
plan: {
id: string;
name: string;
limits: PlanLimits;
};
}>({
method: 'GET',
path: `/api/organizations/${organizationId}/subscription`,
});
@@ -39,7 +47,7 @@ export async function fetchOrganizationUsage({ organizationId }: { organizationI
intakeEmailsCount: { used: number; limit: number | null };
membersCount: { used: number; limit: number | null };
};
limits: { maxDocumentsSize: number | null; maxIntakeEmailsCount: number | null; maxOrganizationsMembersCount: number | null };
limits: PlanLimits;
}>({
method: 'GET',
path: `/api/organizations/${organizationId}/usage`,

View File

@@ -212,7 +212,7 @@ export const OrganizationLayout: ParentComponent = (props) => {
));
return (
<DocumentUploadProvider>
<DocumentUploadProvider organizationId={params.organizationId}>
<SidenavLayout
children={props.children}
sideNav={OrganizationLayoutSideNav}

View File

@@ -179,7 +179,7 @@ export const SidenavLayout: ParentComponent<{
const { getPendingInvitationsCount } = usePendingInvitationsCount();
const { t } = useI18n();
const { promptImport, uploadDocuments } = useDocumentUpload({ getOrganizationId: () => params.organizationId });
const { promptImport, uploadDocuments } = useDocumentUpload();
return (
<div class="flex flex-row h-screen min-h-0">

View File

@@ -73,9 +73,6 @@ describe('config models', () => {
intakeEmails: {
isEnabled: true,
},
documentsStorage: {
maxUploadSize: 10485760,
},
organizations: {
deletedOrganizationsPurgeDaysDelay: 30,
},

View File

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

View File

@@ -228,16 +228,17 @@ function setupGetOrganizationSubscriptionUsageRoute({ app, db, config }: RouteDe
]);
const nullifiedLimits = {
maxDocumentsSize: nullifyPositiveInfinity(organizationPlan.limits.maxDocumentStorageBytes),
maxDocumentStorageBytes: nullifyPositiveInfinity(organizationPlan.limits.maxDocumentStorageBytes),
maxIntakeEmailsCount: nullifyPositiveInfinity(organizationPlan.limits.maxIntakeEmailsCount),
maxOrganizationsMembersCount: nullifyPositiveInfinity(organizationPlan.limits.maxOrganizationsMembersCount),
maxFileSize: nullifyPositiveInfinity(organizationPlan.limits.maxFileSize),
};
return context.json({
usage: {
documentsStorage: {
used: documentsSize,
limit: nullifiedLimits.maxDocumentsSize,
limit: nullifiedLimits.maxDocumentStorageBytes,
},
intakeEmailsCount: {
used: intakeEmailCount,