mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-17 12:15:22 -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),
|
||||
},
|
||||
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'>;
|
||||
|
||||
@@ -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 });
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
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 { 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 = () => {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -212,7 +212,7 @@ export const OrganizationLayout: ParentComponent = (props) => {
|
||||
));
|
||||
|
||||
return (
|
||||
<DocumentUploadProvider>
|
||||
<DocumentUploadProvider organizationId={params.organizationId}>
|
||||
<SidenavLayout
|
||||
children={props.children}
|
||||
sideNav={OrganizationLayoutSideNav}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -73,9 +73,6 @@ describe('config models', () => {
|
||||
intakeEmails: {
|
||||
isEnabled: true,
|
||||
},
|
||||
documentsStorage: {
|
||||
maxUploadSize: 10485760,
|
||||
},
|
||||
organizations: {
|
||||
deletedOrganizationsPurgeDaysDelay: 30,
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user