mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-20 12:19:46 -06:00
fix(upload): use organization-specific file size limits (#555)
This commit is contained in:
committed by
GitHub
parent
bb1ba3e15e
commit
c5b337f3bb
5
.changeset/tired-ravens-work.md
Normal file
5
.changeset/tired-ravens-work.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@papra/docker": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Use organization max file size limit for pre-upload validation
|
||||||
@@ -40,10 +40,7 @@ export const buildTimeConfig = {
|
|||||||
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false),
|
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false),
|
||||||
},
|
},
|
||||||
isSubscriptionsEnabled: asBoolean(import.meta.env.VITE_IS_SUBSCRIPTIONS_ENABLED, false),
|
isSubscriptionsEnabled: asBoolean(import.meta.env.VITE_IS_SUBSCRIPTIONS_ENABLED, false),
|
||||||
documentsStorage: {
|
|
||||||
maxUploadSize: asNumber(import.meta.env.VITE_DOCUMENTS_STORAGE_MAX_UPLOAD_SIZE, 10 * 1024 * 1024),
|
|
||||||
},
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Config = typeof buildTimeConfig;
|
export type Config = typeof buildTimeConfig;
|
||||||
export type RuntimePublicConfig = Pick<Config, 'auth' | 'documents' | 'documentsStorage' | 'intakeEmails' | 'organizations'>;
|
export type RuntimePublicConfig = Pick<Config, 'auth' | 'documents' | 'intakeEmails' | 'organizations'>;
|
||||||
|
|||||||
@@ -2,23 +2,24 @@ import type { ParentComponent } from 'solid-js';
|
|||||||
import type { Document } from '../documents.types';
|
import type { Document } from '../documents.types';
|
||||||
import { safely } from '@corentinth/chisels';
|
import { safely } from '@corentinth/chisels';
|
||||||
import { A } from '@solidjs/router';
|
import { A } from '@solidjs/router';
|
||||||
|
import { useQuery } from '@tanstack/solid-query';
|
||||||
import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js';
|
import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js';
|
||||||
import { Portal } from 'solid-js/web';
|
import { Portal } from 'solid-js/web';
|
||||||
import { useConfig } from '@/modules/config/config.provider';
|
|
||||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||||
import { promptUploadFiles } from '@/modules/shared/files/upload';
|
import { promptUploadFiles } from '@/modules/shared/files/upload';
|
||||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||||
import { cn } from '@/modules/shared/style/cn';
|
import { cn } from '@/modules/shared/style/cn';
|
||||||
import { throttle } from '@/modules/shared/utils/timing';
|
import { throttle } from '@/modules/shared/utils/timing';
|
||||||
|
import { fetchOrganizationSubscription } from '@/modules/subscriptions/subscriptions.services';
|
||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
|
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
|
||||||
import { uploadDocument } from '../documents.services';
|
import { uploadDocument } from '../documents.services';
|
||||||
|
|
||||||
const DocumentUploadContext = createContext<{
|
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);
|
const context = useContext(DocumentUploadContext);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
@@ -28,11 +29,11 @@ export function useDocumentUpload({ getOrganizationId }: { getOrganizationId: ()
|
|||||||
const { uploadDocuments } = context;
|
const { uploadDocuments } = context;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId: getOrganizationId() }),
|
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files }),
|
||||||
promptImport: async () => {
|
promptImport: async () => {
|
||||||
const { files } = await promptUploadFiles();
|
const { files } = await promptUploadFiles();
|
||||||
|
|
||||||
await uploadDocuments({ files, organizationId: getOrganizationId() });
|
await uploadDocuments({ files });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -54,11 +55,10 @@ type Task = TaskSuccess | TaskError | {
|
|||||||
status: 'pending' | 'uploading';
|
status: 'pending' | 'uploading';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentUploadProvider: ParentComponent = (props) => {
|
export const DocumentUploadProvider: ParentComponent<{ organizationId: string }> = (props) => {
|
||||||
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);
|
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);
|
||||||
const { getErrorMessage } = useI18nApiErrors();
|
const { getErrorMessage } = useI18nApiErrors();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { config } = useConfig();
|
|
||||||
|
|
||||||
const [getState, setState] = createSignal<'open' | 'closed' | 'collapsed'>('closed');
|
const [getState, setState] = createSignal<'open' | 'closed' | 'collapsed'>('closed');
|
||||||
const [getTasks, setTasks] = createSignal<Task[]>([]);
|
const [getTasks, setTasks] = createSignal<Task[]>([]);
|
||||||
@@ -67,20 +67,33 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
|
|||||||
setTasks(tasks => tasks.map(task => task.file === args.file ? { ...task, ...args } : task));
|
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))]);
|
setTasks(tasks => [...tasks, ...files.map(file => ({ file, status: 'pending' } as const))]);
|
||||||
setState('open');
|
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) => {
|
await Promise.all(files.map(async (file) => {
|
||||||
const { maxUploadSize } = config.documentsStorage;
|
|
||||||
updateTaskStatus({ file, status: 'uploading' });
|
updateTaskStatus({ file, status: 'uploading' });
|
||||||
|
|
||||||
if (maxUploadSize > 0 && file.size > maxUploadSize) {
|
// 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' }) });
|
updateTaskStatus({ file, status: 'error', error: Object.assign(new Error('File too large'), { code: 'document.size_too_large' }) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [result, error] = await safely(uploadDocument({ file, organizationId }));
|
const [result, error] = await safely(uploadDocument({ file, organizationId: props.organizationId }));
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
updateTaskStatus({ file, status: 'error', error });
|
updateTaskStatus({ file, status: 'error', error });
|
||||||
@@ -90,7 +103,7 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
|
|||||||
updateTaskStatus({ file, status: 'success', document });
|
updateTaskStatus({ file, status: 'success', document });
|
||||||
}
|
}
|
||||||
|
|
||||||
throttledInvalidateOrganizationDocumentsQuery({ organizationId });
|
throttledInvalidateOrganizationDocumentsQuery({ organizationId: props.organizationId });
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { useParams } from '@solidjs/router';
|
|
||||||
import { createSignal } from 'solid-js';
|
import { createSignal } from 'solid-js';
|
||||||
import { cn } from '@/modules/shared/style/cn';
|
import { cn } from '@/modules/shared/style/cn';
|
||||||
import { Button } from '@/modules/ui/components/button';
|
import { Button } from '@/modules/ui/components/button';
|
||||||
import { useDocumentUpload } from './document-import-status.component';
|
import { useDocumentUpload } from './document-import-status.component';
|
||||||
|
|
||||||
export const DocumentUploadArea: Component<{ organizationId?: string }> = (props) => {
|
export const DocumentUploadArea: Component = () => {
|
||||||
const [isDragging, setIsDragging] = createSignal(false);
|
const [isDragging, setIsDragging] = createSignal(false);
|
||||||
const params = useParams();
|
|
||||||
|
|
||||||
const getOrganizationId = () => props.organizationId ?? params.organizationId;
|
const { promptImport, uploadDocuments } = useDocumentUpload();
|
||||||
|
|
||||||
const { promptImport, uploadDocuments } = useDocumentUpload({ getOrganizationId });
|
|
||||||
|
|
||||||
const handleDragOver = (event: DragEvent) => {
|
const handleDragOver = (event: DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const OrganizationPage: Component = () => {
|
|||||||
queryFn: () => getOrganizationDocumentsStats({ organizationId: params.organizationId }),
|
queryFn: () => getOrganizationDocumentsStats({ organizationId: params.organizationId }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { promptImport } = useDocumentUpload({ getOrganizationId: () => params.organizationId });
|
const { promptImport } = useDocumentUpload();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||||
|
|||||||
6
apps/papra-client/src/modules/plans/plans.types.ts
Normal file
6
apps/papra-client/src/modules/plans/plans.types.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type PlanLimits = {
|
||||||
|
maxDocumentStorageBytes: number | null;
|
||||||
|
maxIntakeEmailsCount: number | null;
|
||||||
|
maxOrganizationsMembersCount: number | null;
|
||||||
|
maxFileSize: number | null;
|
||||||
|
};
|
||||||
@@ -24,11 +24,11 @@ export const UsageWarningCard: Component<{ organizationId: string }> = (props) =
|
|||||||
const getStorageSizeUsedPercent = () => {
|
const getStorageSizeUsedPercent = () => {
|
||||||
const { data: usageData } = query;
|
const { data: usageData } = query;
|
||||||
|
|
||||||
if (!usageData || usageData.limits.maxDocumentsSize === null) {
|
if (!usageData || usageData.usage.documentsStorage.limit === null) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (usageData.usage.documentsStorage.used / usageData.limits.maxDocumentsSize) * 100;
|
return (usageData.usage.documentsStorage.used / usageData.usage.documentsStorage.limit) * 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldShow = () => {
|
const shouldShow = () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { PlanLimits } from '../plans/plans.types';
|
||||||
import type { OrganizationSubscription } from './subscriptions.types';
|
import type { OrganizationSubscription } from './subscriptions.types';
|
||||||
import { apiClient } from '../shared/http/api-client';
|
import { apiClient } from '../shared/http/api-client';
|
||||||
|
|
||||||
@@ -24,7 +25,14 @@ export async function getCustomerPortalUrl({ organizationId }: { organizationId:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOrganizationSubscription({ organizationId }: { organizationId: string }) {
|
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',
|
method: 'GET',
|
||||||
path: `/api/organizations/${organizationId}/subscription`,
|
path: `/api/organizations/${organizationId}/subscription`,
|
||||||
});
|
});
|
||||||
@@ -39,7 +47,7 @@ export async function fetchOrganizationUsage({ organizationId }: { organizationI
|
|||||||
intakeEmailsCount: { used: number; limit: number | null };
|
intakeEmailsCount: { used: number; limit: number | null };
|
||||||
membersCount: { 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',
|
method: 'GET',
|
||||||
path: `/api/organizations/${organizationId}/usage`,
|
path: `/api/organizations/${organizationId}/usage`,
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export const OrganizationLayout: ParentComponent = (props) => {
|
|||||||
));
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentUploadProvider>
|
<DocumentUploadProvider organizationId={params.organizationId}>
|
||||||
<SidenavLayout
|
<SidenavLayout
|
||||||
children={props.children}
|
children={props.children}
|
||||||
sideNav={OrganizationLayoutSideNav}
|
sideNav={OrganizationLayoutSideNav}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export const SidenavLayout: ParentComponent<{
|
|||||||
const { getPendingInvitationsCount } = usePendingInvitationsCount();
|
const { getPendingInvitationsCount } = usePendingInvitationsCount();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { promptImport, uploadDocuments } = useDocumentUpload({ getOrganizationId: () => params.organizationId });
|
const { promptImport, uploadDocuments } = useDocumentUpload();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-row h-screen min-h-0">
|
<div class="flex flex-row h-screen min-h-0">
|
||||||
|
|||||||
@@ -73,9 +73,6 @@ describe('config models', () => {
|
|||||||
intakeEmails: {
|
intakeEmails: {
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
},
|
},
|
||||||
documentsStorage: {
|
|
||||||
maxUploadSize: 10485760,
|
|
||||||
},
|
|
||||||
organizations: {
|
organizations: {
|
||||||
deletedOrganizationsPurgeDaysDelay: 30,
|
deletedOrganizationsPurgeDaysDelay: 30,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export function getPublicConfig({ config }: { config: Config }) {
|
|||||||
'auth.providers.github.isEnabled',
|
'auth.providers.github.isEnabled',
|
||||||
'auth.providers.google.isEnabled',
|
'auth.providers.google.isEnabled',
|
||||||
'documents.deletedDocumentsRetentionDays',
|
'documents.deletedDocumentsRetentionDays',
|
||||||
'documentsStorage.maxUploadSize',
|
|
||||||
'intakeEmails.isEnabled',
|
'intakeEmails.isEnabled',
|
||||||
'organizations.deletedOrganizationsPurgeDaysDelay',
|
'organizations.deletedOrganizationsPurgeDaysDelay',
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -228,16 +228,17 @@ function setupGetOrganizationSubscriptionUsageRoute({ app, db, config }: RouteDe
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const nullifiedLimits = {
|
const nullifiedLimits = {
|
||||||
maxDocumentsSize: nullifyPositiveInfinity(organizationPlan.limits.maxDocumentStorageBytes),
|
maxDocumentStorageBytes: nullifyPositiveInfinity(organizationPlan.limits.maxDocumentStorageBytes),
|
||||||
maxIntakeEmailsCount: nullifyPositiveInfinity(organizationPlan.limits.maxIntakeEmailsCount),
|
maxIntakeEmailsCount: nullifyPositiveInfinity(organizationPlan.limits.maxIntakeEmailsCount),
|
||||||
maxOrganizationsMembersCount: nullifyPositiveInfinity(organizationPlan.limits.maxOrganizationsMembersCount),
|
maxOrganizationsMembersCount: nullifyPositiveInfinity(organizationPlan.limits.maxOrganizationsMembersCount),
|
||||||
|
maxFileSize: nullifyPositiveInfinity(organizationPlan.limits.maxFileSize),
|
||||||
};
|
};
|
||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
usage: {
|
usage: {
|
||||||
documentsStorage: {
|
documentsStorage: {
|
||||||
used: documentsSize,
|
used: documentsSize,
|
||||||
limit: nullifiedLimits.maxDocumentsSize,
|
limit: nullifiedLimits.maxDocumentStorageBytes,
|
||||||
},
|
},
|
||||||
intakeEmailsCount: {
|
intakeEmailsCount: {
|
||||||
used: intakeEmailCount,
|
used: intakeEmailCount,
|
||||||
|
|||||||
Reference in New Issue
Block a user