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

};
}

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?

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