mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user