Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/core/buckets/Bucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface Bucket {
status: string;
transfer: number;
storage: number;
usedSpaceBytes?: number;
created?: Date;
maxFrameSize?: number;
publicPermissions?: string[];
Expand Down
22 changes: 22 additions & 0 deletions lib/core/buckets/MongoDBBucketsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,34 @@ export class MongoDBBucketsRepository implements BucketsRepository {
return formatFromMongoToBucket(rawModel);
}

async sumUsedSpaceBytes(userId: Bucket['userId']): Promise<number> {
const [result] = await this.model.aggregate([
{ $match: { userId } },
{ $group: { _id: null, total: { $sum: { $ifNull: ['$usedSpaceBytes', 0] } } } }
]);

return result ? result.total : 0;
}

async destroyByUser(userId: Bucket['userId']): Promise<void> {
await this.model.deleteMany({
userId,
});
}

async setUsedSpaceBytes(
bucketId: Bucket['id'],
userId: Bucket['userId'],
usedSpaceBytes: number
): Promise<boolean> {
const result = await this.model.updateOne(
{ _id: bucketId, userId },
{ $set: { usedSpaceBytes } }
);

return result.matchedCount > 0;
}

async removeByIdAndUser(bucketId: Bucket['id'], userId: Bucket['userId']): Promise<void> {
await this.model.deleteOne({
userId,
Expand Down
8 changes: 7 additions & 1 deletion lib/core/buckets/Repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ export interface BucketsRepository {
findByIds(ids: Bucket['id'][]): Promise<Bucket[]>;
find(where: Partial<Bucket>): Promise<Bucket[]>;
findUserBucketsFromDate(userId: Bucket['id'], date?: Date, limit?: number): Promise<Bucket[]>;
setUsedSpaceBytes(
bucketId: Bucket['id'],
userId: Bucket['userId'],
usedSpaceBytes: number
): Promise<boolean>;
sumUsedSpaceBytes(userId: Bucket['userId']): Promise<number>;
destroyByUser(userId: Bucket['userId']): Promise<void>;
removeAll(where: Partial<Bucket>): Promise<void>;
removeByIdAndUser(bucketId: Bucket['id'], userId: Bucket['userId']): Promise<void>
removeByIdAndUser(bucketId: Bucket['id'], userId: Bucket['userId']): Promise<void>
}
37 changes: 37 additions & 0 deletions lib/core/users/usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createHash, randomBytes } from 'crypto';

import { UsersRepository } from './Repository';
import { BucketsRepository } from '../buckets/Repository';
import { BucketNotFoundError } from '../buckets/usecase';
import { MailUsecase } from '../mail/usecase';
import { EventBus, EventBusEvents } from '../../server/eventBus';
import { FramesRepository } from '../frames/Repository';
Expand All @@ -20,6 +21,11 @@ function isEmailValid(email: string) {
export const RESET_PASSWORD_TOKEN_BYTES_LENGTH = 256;
export const SHA256_HASH_BYTES_LENGTH = 32;

export interface UserSpaceSnapshot {
maxSpaceBytes: number;
totalUsedSpaceBytes: number;
}

export class UserAlreadyExistsError extends Error {
constructor() {
super('User already exists');
Expand Down Expand Up @@ -286,12 +292,43 @@ export class UsersUsecase {
status: 'Active',
transfer: 0,
storage: 0,
usedSpaceBytes: 0,
encryptionKey: '',
});

return { id: created.id, name: created.name };
}

async setBucketUsage(
uuid: User['uuid'],
bucketId: string,
usedSpaceBytes: number
): Promise<UserSpaceSnapshot> {
const updated = await this.bucketsRepository.setUsedSpaceBytes(
bucketId,
uuid,
usedSpaceBytes
);

if (!updated) {
throw new BucketNotFoundError(bucketId);
}

const [user, bucketsUsedSpaceBytes] = await Promise.all([
this.usersRepository.findByUuid(uuid),
this.bucketsRepository.sumUsedSpaceBytes(uuid),
]);

if (!user) {
throw new UserNotFoundError(uuid);
}

return {
maxSpaceBytes: user.maxSpaceBytes,
totalUsedSpaceBytes: user.totalUsedSpaceBytes + bucketsUsedSpaceBytes,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why? The user's totalUsedSpaceBytes already includes the bucketsUsedSpaceBytes

@jzunigax2 jzunigax2 Jun 11, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's the main issue here. The network needs to know how much space is consumed by mail. However mail blobs (bodies and attachments) are managed and stored by stalwart that upload path does not come through the network. I though about leveraging sieve scripts, event hooks or similar to also store the metadata on here, though that would create the problem of mantaining both

as it stands mail just reports the its usage which would not be part of totalUsedSpaceBytes as its contents did not go through the normal upload flow

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We cannot allow people to use more space than paid, that's against the business costs to operate. We bill for the space they use. If we are not controlling that at all, we have a operational issue. The totalUsage of the user should be the real total. In fact, if that is not respected, Drive may consume more space than it should, which is again, a hidden overcost

};
}

async deleteBucket(uuid: User['uuid'], bucketId: string): Promise<void> {
const user = await this.usersRepository.findByUuid(uuid);

Expand Down
2 changes: 2 additions & 0 deletions lib/models/bucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const errors = require("storj-service-error-types");

interface IBucket extends Document {
storage: number;
usedSpaceBytes: number;
transfer: number;
status: "Active" | "Inactive";
pubkeys: string[];
Expand All @@ -20,6 +21,7 @@ interface IBucket extends Document {
const BucketSchema = new Schema<IBucket>(
{
storage: { type: Number, default: 0 },
usedSpaceBytes: { type: Number, default: 0 },
transfer: { type: Number, default: 0 },
status: {
type: String,
Expand Down
48 changes: 47 additions & 1 deletion lib/server/http/gateway/controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Request, Response } from 'express';
import { Logger } from 'winston';
import { EmailIsAlreadyInUseError, InvalidDataFormatError, UserAlreadyExistsError, UserNotFoundError, UsersUsecase } from '../../../core';
import { EmailIsAlreadyInUseError, InvalidDataFormatError, UserAlreadyExistsError, UserNotFoundError, UserSpaceSnapshot, UsersUsecase } from '../../../core';
import { BucketEntriesUsecase } from '../../../core/bucketEntries/usecase';
import { BucketNotFoundError } from '../../../core/buckets/usecase';

import { GatewayUsecase } from '../../../core/gateway/Usecase';
import { EventBus, EventBusEvents, UserStorageChangedPayload } from '../../eventBus';
Expand All @@ -16,6 +17,13 @@ type DeleteFilesInBulkResponse = {
type CreateBucketBody = { name: string };
type CreateBucketResponse = { id: string; name: string };

type SetBucketUsageBody = { usedSpaceBytes: number };

const OBJECT_ID_PATTERN = /^[a-f0-9]{24}$/i;

const isValidUsedSpaceBytes = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value) && value >= 0;

export class HTTPGatewayController {
constructor(
private gatewayUsecase: GatewayUsecase,
Expand Down Expand Up @@ -165,6 +173,44 @@ export class HTTPGatewayController {
}
}

async setBucketUsage(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not know if it fits you but, isn't it better to set this on any upload so when you use the upload endpoint for mail, the computation for that bucket is set?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to the above comment #220 (comment), mail doesnt go through the normal upload path

req: Request<{ uuid: string; id: string }, {}, Partial<SetBucketUsageBody>, {}>,
res: Response<UserSpaceSnapshot | { message: string }>
) {
const { uuid, id } = req.params;
const { usedSpaceBytes } = req.body;

if (!uuid || !id || !OBJECT_ID_PATTERN.test(id)) {
return res.status(400).send({ message: 'Invalid params' });
}

if (!isValidUsedSpaceBytes(usedSpaceBytes)) {
return res
.status(400)
.send({ message: 'usedSpaceBytes must be a non-negative number' });
}

try {
const snapshot = await this.usersUsecase.setBucketUsage(uuid, id, usedSpaceBytes);

return res.status(200).send(snapshot);
} catch (err) {
if (err instanceof UserNotFoundError || err instanceof BucketNotFoundError) {
return res.status(404).send({ message: err.message });
}

this.logger.error(
'[GATEWAY/BUCKET_USAGE] Error setting usage for bucket %s of user %s: %s. %s',
id,
uuid,
(err as Error).message,
(err as Error).stack || 'NO STACK'
);

return res.status(500).send({ message: 'Internal server error' });
}
}

async deleteUserBucket(
req: Request<{ uuid: string; id: string }>,
res: Response<{ message: string }>
Expand Down
1 change: 1 addition & 0 deletions lib/server/http/gateway/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const createGatewayHTTPRouter = (
router.patch('/users/:uuid', jwtMiddleware, controller.updateUserEmail.bind(controller));
router.put('/storage/users/:uuid', jwtMiddleware, controller.changeStorage.bind(controller));
router.post('/users/:uuid/buckets', jwtMiddleware, controller.createUserBucket.bind(controller));
router.put('/users/:uuid/buckets/:id/usage', jwtMiddleware, controller.setBucketUsage.bind(controller));
router.delete('/users/:uuid/buckets/:id', jwtMiddleware, controller.deleteUserBucket.bind(controller));
router.delete('/storage/files', jwtMiddleware, controller.deleteFilesInBulk.bind(controller));

Expand Down
47 changes: 47 additions & 0 deletions tests/lib/core/users/usecase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { MongoDBFramesRepository } from '../../../../lib/core/frames/MongoDBFramesRepository';
import { FramesRepository } from '../../../../lib/core/frames/Repository';
import { BucketsRepository } from '../../../../lib/core/buckets/Repository';
import { BucketNotFoundError } from '../../../../lib/core/buckets/usecase';
import { Mailer, MailUsecase, SendGridMailUsecase } from '../../../../lib/core/mail/usecase';
import { EventBus, EventBusEvents } from '../../../../lib/server/eventBus';
import { User } from '../../../../lib/core/users/User';
Expand Down Expand Up @@ -451,6 +452,7 @@ describe('Users usecases', () => {
status: 'Active',
transfer: 0,
storage: 0,
usedSpaceBytes: 0,
encryptionKey: '',
}]);
expect(result).toStrictEqual({ id: createdBucket.id, name: createdBucket.name });
Expand Down Expand Up @@ -503,6 +505,51 @@ describe('Users usecases', () => {
});
});

describe('Setting bucket usage', () => {
it('When the bucket does not belong to the user or does not exist, then it throws BucketNotFoundError', async () => {
const setUsage = stub(bucketsRepository, 'setUsedSpaceBytes').resolves(false);

try {
await usecase.setBucketUsage('user-uuid', 'bucket-id', 1024);
expect(true).toBeFalsy();
} catch (err) {
expect(err).toBeInstanceOf(BucketNotFoundError);
}

expect(setUsage.calledOnceWithExactly('bucket-id', 'user-uuid', 1024)).toBeTruthy();
});

it('When the bucket belongs to the user, then it stores the reported usage without touching the user total', async () => {
const user = fixtures.getUser({ maxSpaceBytes: 10000, totalUsedSpaceBytes: 4000 });

const setUsage = stub(bucketsRepository, 'setUsedSpaceBytes').resolves(true);
const addUsage = stub(usersRepository, 'addTotalUsedSpaceBytes').resolves();
stub(usersRepository, 'findByUuid').resolves(user);
stub(bucketsRepository, 'sumUsedSpaceBytes').resolves(2048);

await usecase.setBucketUsage(user.uuid, 'bucket-id', 2048);

expect(setUsage.calledOnceWithExactly('bucket-id', user.uuid, 2048)).toBeTruthy();
expect(addUsage.called).toBeFalsy();
});

it('When the usage is stored, then it returns the user space snapshot with the buckets usage summed in', async () => {
const user = fixtures.getUser({ maxSpaceBytes: 10000, totalUsedSpaceBytes: 4000 });

stub(bucketsRepository, 'setUsedSpaceBytes').resolves(true);
stub(usersRepository, 'findByUuid').resolves(user);
const sumUsage = stub(bucketsRepository, 'sumUsedSpaceBytes').resolves(1500);

const snapshot = await usecase.setBucketUsage(user.uuid, 'bucket-id', 1500);

expect(sumUsage.calledOnceWithExactly(user.uuid)).toBeTruthy();
expect(snapshot).toStrictEqual({
maxSpaceBytes: 10000,
totalUsedSpaceBytes: 5500,
});
});
});

describe('Confirming user destruction', () => {
it('When confirming a destruction of a user that exists, then it works', async () => {
const user = fixtures.getUser();
Expand Down
Loading
Loading