Skip to content
2 changes: 1 addition & 1 deletion lib/core/bucketEntries/BucketEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface BucketEntry {
name?: string;
// filename is deprecated
filename?: string;
index: string;
index?: string;
bucket: Bucket['id'];
created?: Date;
mimetype?: string;
Expand Down
73 changes: 73 additions & 0 deletions lib/core/bucketEntries/usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PointersRepository } from '../pointers/Repository';
import { MirrorsRepository } from '../mirrors/Repository';
import { BucketEntry } from './BucketEntry';
import { UsersRepository } from '../users/Repository';
import { UserNotFoundError, UserSpaceSnapshot } from '../users';
import { User } from '../users/User';
import { Bucket } from '../buckets/Bucket';
import { FileStateRepository } from '../fileState/Repository';
Expand Down Expand Up @@ -181,4 +182,76 @@ export class BucketEntriesUsecase {
await this.bucketEntriesRepository.deleteByIds(fileIds);
await this.fileStateRepository.deleteByBucketEntryIds(fileIds);
}

private async findBucketOwner(
userUuid: User['uuid'],
bucketId: Bucket['id']
): Promise<User> {
const user = await this.usersRepository.findByUuid(userUuid);

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

const bucket = await this.bucketsRepository.findOne({ id: bucketId, userId: userUuid });

if (!bucket) {
throw new BucketNotFoundError();
}

return user;
Comment thread
sg-gs marked this conversation as resolved.
}

async createEntry(
userUuid: User['uuid'],
bucketId: Bucket['id'],
size: number
): Promise<{ id: BucketEntry['id']; snapshot: UserSpaceSnapshot }> {
const user = await this.findBucketOwner(userUuid, bucketId);

const entry = await this.bucketEntriesRepository.create({
bucket: bucketId,
size,
version: 2,
});

const totalUsedSpaceBytes = await this.usersRepository.addTotalUsedSpaceBytes(userUuid, size);

return {
id: entry.id,
snapshot: {
maxSpaceBytes: user.maxSpaceBytes,
totalUsedSpaceBytes,
},
};
}

async removeEntry(
userUuid: User['uuid'],
bucketId: Bucket['id'],
entryId: BucketEntry['id']
): Promise<UserSpaceSnapshot> {
const user = await this.findBucketOwner(userUuid, bucketId);

const entry = await this.bucketEntriesRepository.findOne({ id: entryId, bucket: bucketId });

if (!entry) {
return {
maxSpaceBytes: user.maxSpaceBytes,
totalUsedSpaceBytes: user.totalUsedSpaceBytes,
};
}

await this.removeFilesV2([entry]);

const totalUsedSpaceBytes = await this.usersRepository.addTotalUsedSpaceBytes(
userUuid,
-(entry.size || 0)
);

return {
maxSpaceBytes: user.maxSpaceBytes,
totalUsedSpaceBytes,
};
}
}
2 changes: 1 addition & 1 deletion lib/core/buckets/Repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ export interface BucketsRepository {
findUserBucketsFromDate(userId: Bucket['id'], date?: Date, limit?: number): Promise<Bucket[]>;
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>
}
11 changes: 7 additions & 4 deletions lib/core/users/MongoDBUsersRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,17 @@ export class MongoDBUsersRepository implements UsersRepository {
await this.userModel.updateOne({ uuid }, { $set: update });
}

addTotalUsedSpaceBytes(
async addTotalUsedSpaceBytes(
uuid: string,
totalUsedSpaceBytes: number
): Promise<void> {
return this.userModel.updateOne(
): Promise<number> {
const user = await this.userModel.findOneAndUpdate(
{ uuid },
{ $inc: { totalUsedSpaceBytes } }
{ $inc: { totalUsedSpaceBytes } },
{ new: true }
);

return user?.totalUsedSpaceBytes ?? 0;
}

removeById(id: User['id']) {
Expand Down
2 changes: 1 addition & 1 deletion lib/core/users/Repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface UsersRepository {
findByEmail(email: User['email']): Promise<User | null>;
findByIds(ids: User['id'][]): Promise<User[]>;
create(data: CreateUserData): Promise<BasicUser>;
addTotalUsedSpaceBytes(uuid: User['uuid'], totalUsedSpaceBytes: User['totalUsedSpaceBytes']): Promise<void>;
addTotalUsedSpaceBytes(uuid: User['uuid'], totalUsedSpaceBytes: User['totalUsedSpaceBytes']): Promise<User['totalUsedSpaceBytes']>;
updateById(id: User['id'], update: Partial<User>): Promise<User | null>;
updateByEmail(email: User['email'], update: Partial<User>): Promise<User | null>;
updateByUuid(uuid: User['uuid'], update: Partial<User>): Promise<void>;
Expand Down
5 changes: 5 additions & 0 deletions lib/core/users/usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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
84 changes: 83 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,14 @@ type DeleteFilesInBulkResponse = {
type CreateBucketBody = { name: string };
type CreateBucketResponse = { id: string; name: string };

type CreateBucketEntryBody = { size: number };
type CreateBucketEntryResponse = UserSpaceSnapshot & { id: string };

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

const isValidEntrySize = (value: unknown): value is number =>
Comment thread
sg-gs marked this conversation as resolved.
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;

export class HTTPGatewayController {
constructor(
private gatewayUsecase: GatewayUsecase,
Expand Down Expand Up @@ -164,4 +173,77 @@ export class HTTPGatewayController {
return res.status(500).send({ message: 'Internal server error' });
}
}

async createBucketEntry(
req: Request<{ uuid: string; id: string }, {}, Partial<CreateBucketEntryBody>, {}>,
res: Response<CreateBucketEntryResponse | { message: string }>
) {
const { uuid, id } = req.params;
const { size } = req.body;

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

if (!isValidEntrySize(size)) {
return res
.status(400)
.send({ message: 'size must be a positive integer' });
}

try {
const { id: entryId, snapshot } = await this.bucketEntriesUsecase.createEntry(
uuid,
id,
size
);

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

this.logger.error(
'[GATEWAY/CREATE_ENTRY] Error creating entry on 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 deleteBucketEntry(
req: Request<{ uuid: string; id: string; entryId: string }>,
res: Response<UserSpaceSnapshot | { message: string }>
) {
const { uuid, id, entryId } = req.params;

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

try {
const snapshot = await this.bucketEntriesUsecase.removeEntry(uuid, id, entryId);

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/DELETE_ENTRY] Error deleting entry on 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' });
}
}
}
2 changes: 2 additions & 0 deletions lib/server/http/gateway/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ 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.post('/users/:uuid/buckets/:id/entries', jwtMiddleware, controller.createBucketEntry.bind(controller));
router.delete('/users/:uuid/buckets/:id/entries/:entryId', jwtMiddleware, controller.deleteBucketEntry.bind(controller));
router.delete('/storage/files', jwtMiddleware, controller.deleteFilesInBulk.bind(controller));

return router;
Expand Down
97 changes: 96 additions & 1 deletion tests/lib/core/bucketentries/usecase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { MongoDBMirrorsRepository } from '../../../../lib/core/mirrors/MongoDBMi
import { MongoDBPointersRepository } from '../../../../lib/core/pointers/MongoDBPointersRepository';
import { MongoDBShardsRepository } from '../../../../lib/core/shards/MongoDBShardsRepository';
import { MongoDBBucketEntryShardsRepository } from '../../../../lib/core/bucketEntryShards/MongoDBBucketEntryShardsRepository';
import { MongoDBUsersRepository } from '../../../../lib/core/users';
import { MongoDBUsersRepository, UserNotFoundError } from '../../../../lib/core/users';
import { ShardsUsecase } from '../../../../lib/core/shards/usecase';

import fixtures from '../fixtures';
Expand Down Expand Up @@ -658,4 +658,99 @@ describe('BucketEntriesUsecase', function () {
expect(removeFileStub.calledWith(fileId)).toBeTruthy();
});
});

describe('createEntry()', () => {
it('When the user does not exist, then it throws UserNotFoundError', async () => {
stub(usersRepository, 'findByUuid').resolves(null);
const create = stub(bucketEntriesRepository, 'create');

try {
await bucketEntriesUsecase.createEntry('unknown-uuid', 'bucket-id', 100);
expect(true).toBeFalsy();
} catch (err) {
expect(err).toBeInstanceOf(UserNotFoundError);
}

expect(create.called).toBeFalsy();
});

it('When the bucket does not belong to the user or does not exist, then it throws BucketNotFoundError', async () => {
const user = fixtures.getUser();

stub(usersRepository, 'findByUuid').resolves(user);
const findBucket = stub(bucketsRepository, 'findOne').resolves(null);
const create = stub(bucketEntriesRepository, 'create');

try {
await bucketEntriesUsecase.createEntry(user.uuid, 'bucket-id', 100);
expect(true).toBeFalsy();
} catch (err) {
expect(err).toBeInstanceOf(BucketNotFoundError);
}

expect(findBucket.calledOnceWithExactly({ id: 'bucket-id', userId: user.uuid })).toBeTruthy();
expect(create.called).toBeFalsy();
});

it('When the entry is created, then it adds the size to the user total and returns the entry id', async () => {
const user = fixtures.getUser({ maxSpaceBytes: 10000, totalUsedSpaceBytes: 4000 });
const bucket = fixtures.getBucket({ userId: user.uuid });
const entry = fixtures.getBucketEntry({ bucket: bucket.id, size: 500 });

stub(usersRepository, 'findByUuid').resolves(user);
stub(bucketsRepository, 'findOne').resolves(bucket);
const create = stub(bucketEntriesRepository, 'create').resolves(entry);
const addUsage = stub(usersRepository, 'addTotalUsedSpaceBytes').resolves(4500);

const result = await bucketEntriesUsecase.createEntry(user.uuid, bucket.id, 500);

expect(create.calledOnceWithExactly(
{ bucket: bucket.id, size: 500, version: 2 }
)).toBeTruthy();
expect(addUsage.calledOnceWithExactly(user.uuid, 500)).toBeTruthy();
expect(result).toStrictEqual({
id: entry.id,
snapshot: { maxSpaceBytes: 10000, totalUsedSpaceBytes: 4500 },
});
});
});

describe('removeEntry()', () => {
it('When the entry does not exist, then it is a no-op and the snapshot is unchanged', async () => {
const user = fixtures.getUser({ maxSpaceBytes: 10000, totalUsedSpaceBytes: 4000 });
const bucket = fixtures.getBucket({ userId: user.uuid });
const entryId = 'aaaaaaaaaaaaaaaaaaaaaaaa';

stub(usersRepository, 'findByUuid').resolves(user);
stub(bucketsRepository, 'findOne').resolves(bucket);
const findEntry = stub(bucketEntriesRepository, 'findOne').resolves(null);
const removeFilesV2 = stub(bucketEntriesUsecase, 'removeFilesV2');
const addUsage = stub(usersRepository, 'addTotalUsedSpaceBytes');

const snapshot = await bucketEntriesUsecase.removeEntry(user.uuid, bucket.id, entryId);

expect(findEntry.calledOnceWithExactly({ id: entryId, bucket: bucket.id })).toBeTruthy();
expect(removeFilesV2.called).toBeFalsy();
expect(addUsage.called).toBeFalsy();
expect(snapshot).toStrictEqual({ maxSpaceBytes: 10000, totalUsedSpaceBytes: 4000 });
});

it('When the entry exists, then it removes it and returns the decremented snapshot', async () => {
const user = fixtures.getUser({ maxSpaceBytes: 10000, totalUsedSpaceBytes: 4000 });
const bucket = fixtures.getBucket({ userId: user.uuid });
const entry = fixtures.getBucketEntry({ bucket: bucket.id, size: 500 });

stub(usersRepository, 'findByUuid').resolves(user);
stub(bucketsRepository, 'findOne').resolves(bucket);
stub(bucketEntriesRepository, 'findOne').resolves(entry);
const removeFilesV2 = stub(bucketEntriesUsecase, 'removeFilesV2').resolves();
const addUsage = stub(usersRepository, 'addTotalUsedSpaceBytes').resolves(3500);

const snapshot = await bucketEntriesUsecase.removeEntry(user.uuid, bucket.id, entry.id);

expect(removeFilesV2.calledOnceWithExactly([entry])).toBeTruthy();
expect(addUsage.calledOnceWithExactly(user.uuid, -500)).toBeTruthy();
expect(snapshot).toStrictEqual({ maxSpaceBytes: 10000, totalUsedSpaceBytes: 3500 });
});
});
});
Loading
Loading