Limit files upload (#10878)

* Limit files upload

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Max size limit

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Add storage usage and adjust styles

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Use mb for file size limit

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-06-16 15:20:48 +07:00
committed by GitHub
parent 9eef305642
commit cfa72c2dac
51 changed files with 2503 additions and 81 deletions
@@ -60,6 +60,7 @@
"dependencies": {
"@hcengineering/analytics": "workspace:^0.7.19",
"@hcengineering/analytics-service": "workspace:^0.7.19",
"@hcengineering/billing": "workspace:^0.7.0",
"@hcengineering/server-token": "workspace:^0.7.18",
"@hcengineering/server-core": "workspace:^0.7.19",
"@hcengineering/server-storage": "workspace:^0.7.16",
@@ -0,0 +1,65 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { getTierLimitsBytes } from '@hcengineering/billing'
import { computeLimitsExceededSince } from '../usage'
describe('computeLimitsExceededSince', () => {
const now = 1_700_000_000_000
it('returns undefined when not exceeded', () => {
expect(computeLimitsExceededSince(undefined, false, now)).toBeUndefined()
})
it('clears the timestamp when usage drops back under the limit', () => {
expect(computeLimitsExceededSince(now - 60_000, false, now)).toBeUndefined()
})
it('starts the timestamp at "now" on the first over-limit observation', () => {
expect(computeLimitsExceededSince(undefined, true, now)).toBe(now)
})
it('preserves the existing timestamp on subsequent over-limit observations', () => {
const earlier = now - 60_000
expect(computeLimitsExceededSince(earlier, true, now)).toBe(earlier)
})
})
describe('getTierLimitsBytes', () => {
it('returns common-tier defaults for unknown plan', () => {
const limits = getTierLimitsBytes('mystery')
expect(limits.storageBytes).toBe(10 * 1e9)
expect(limits.trafficBytes).toBe(10 * 1e9)
})
it('returns common-tier defaults when plan is undefined', () => {
const limits = getTierLimitsBytes(undefined)
expect(limits.storageBytes).toBe(10 * 1e9)
expect(limits.trafficBytes).toBe(10 * 1e9)
})
it('matches plan name case-insensitively', () => {
expect(getTierLimitsBytes('Rare').storageBytes).toBe(100 * 1e9)
expect(getTierLimitsBytes('rare').storageBytes).toBe(100 * 1e9)
expect(getTierLimitsBytes('RARE').storageBytes).toBe(100 * 1e9)
})
it('returns Legendary plan limits', () => {
const limits = getTierLimitsBytes('legendary')
expect(limits.storageBytes).toBe(10000 * 1e9)
expect(limits.trafficBytes).toBe(2000 * 1e9)
})
})
+23 -3
View File
@@ -14,6 +14,7 @@
//
import { type AccountClient, type Subscription, getClient } from '@hcengineering/account-client'
import { getTierLimitsBytes } from '@hcengineering/billing'
import {
type MeasureContext,
type UsageStatus,
@@ -93,7 +94,7 @@ export class UsageWorker {
'update workspace usage statistics',
{},
async (ctx) => {
await this.updateWorkspaceUsageStatistics(ctx, now, workspace.uuid)
await this.updateWorkspaceUsageStatistics(ctx, now, workspace.uuid, workspace.usageInfo)
},
{ workspace: workspace.uuid }
)
@@ -104,7 +105,12 @@ export class UsageWorker {
}
}
async updateWorkspaceUsageStatistics (ctx: MeasureContext, now: number, workspace: WorkspaceUuid): Promise<void> {
async updateWorkspaceUsageStatistics (
ctx: MeasureContext,
now: number,
workspace: WorkspaceUuid,
prevUsage: UsageStatus | undefined
): Promise<void> {
const account = getAccountClient(this.config.AccountsUrl, workspace)
const subscriptions = await account.getSubscriptions(workspace)
@@ -133,16 +139,30 @@ export class UsageWorker {
const livekitTrafficBytes = liveKitUsage.sessions.reduce((acc, session) => acc + session.bandwidth, 0)
const storageBytes = storageUsage.size
const limits = getTierLimitsBytes(subscription?.plan)
const exceeded = storageBytes > limits.storageBytes || livekitTrafficBytes > limits.trafficBytes
const limitsExceededSince = computeLimitsExceededSince(prevUsage?.limitsExceededSince, exceeded, now)
const usage: UsageStatus = {
usage: { livekitTrafficBytes, storageBytes },
startTime: periodStart.getTime(),
updateTime: periodEnd.getTime()
updateTime: periodEnd.getTime(),
limitsExceededSince
}
await account.updateUsageInfo(usage)
}
}
export function computeLimitsExceededSince (
prev: number | undefined,
exceeded: boolean,
now: number
): number | undefined {
if (!exceeded) return undefined
return prev ?? now
}
function getPeriodStartDate (subscription: Subscription | undefined): Date {
if (subscription?.periodStart !== undefined) {
return new Date(subscription.periodStart)
+10 -1
View File
@@ -38,6 +38,12 @@ export interface Config {
Secure: boolean
Readonly: boolean
Cache: CacheConfig
/**
* Maximum size of a single file accepted by the upload endpoint, in bytes.
* Independent of plan-level workspace quota — this is a hard service-level
* cap to protect the storage backend and temp directory.
*/
MaxFileSize: number
}
const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined)
@@ -93,7 +99,10 @@ const config: Config = (() => {
enabled: process.env.CACHE_ENABLED !== 'false',
blobSize: (parseNumber(process.env.CACHE_BLOB_SIZE) ?? 64) * 1024, // Default 64KB
blobCount: parseNumber(process.env.CACHE_BLOB_COUNT) ?? 1000
}
},
// Configured in megabytes via MAX_FILE_SIZE_MB (default 5120 MB = 5 GiB,
// e.g. set to 10240 for 10 GiB).
MaxFileSize: (parseNumber(process.env.MAX_FILE_SIZE_MB) ?? 5120) * 1024 * 1024
}
const missingEnv = (Object.keys(params) as Array<keyof Config>).filter((key) => params[key] === undefined)
+13 -1
View File
@@ -144,7 +144,19 @@ export async function createServer (
const app = express()
app.use(cors())
app.use(express.json({ limit: '50mb' }))
app.use(fileUpload({ useTempFiles: true, tempFileDir: tempDir.path }))
app.use(
fileUpload({
useTempFiles: true,
tempFileDir: tempDir.path,
limits: { fileSize: config.MaxFileSize },
abortOnLimit: true,
// Returned to the client when a file exceeds MaxFileSize. Caught by the
// existing 'File too large' branch in sendErrorToAnalytics below.
limitHandler: (_req, res) => {
res.status(413).send({ code: 413, message: 'File too large' })
}
})
)
app.use(keepAlive({ timeout: KEEP_ALIVE_TIMEOUT, max: KEEP_ALIVE_MAX }))
const childLogger = ctx.logger.childLogger?.('requests', { enableConsole: 'true' })