diff --git a/.github/workflows/deploy-preview-feature.yml b/.github/workflows/deploy-preview-feature.yml new file mode 100644 index 00000000..cb6c1dc5 --- /dev/null +++ b/.github/workflows/deploy-preview-feature.yml @@ -0,0 +1,111 @@ +name: Deploy PR Preview +on: + pull_request: + types: [opened, reopened, synchronize] +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check Out Repo + uses: actions/checkout@v2 + with: + registry-url: 'https://npm.pkg.github.com' + - run: echo "registry=https://registry.yarnpkg.com/" > .npmrc + - run: echo "@internxt:registry=https://npm.pkg.github.com" >> .npmrc + # You cannot read packages from other private repos with GITHUB_TOKEN + # You have to use a PAT instead https://github.com/actions/setup-node/issues/49 + - run: echo //npm.pkg.github.com/:_authToken=${{ secrets.PERSONAL_ACCESS_TOKEN }} >> .npmrc + - run: echo "always-auth=true" >> .npmrc + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Build and push to drive-server-dev + uses: docker/build-push-action@v2 + with: + context: ./ + file: ./Dockerfile + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/drive-server-dev:preview-${{ github.event.number }}-${{ github.event.pull_request.head.sha }} + # add_preview_label: + # runs-on: ubuntu-latest + # needs: build + # steps: + # - uses: actions-ecosystem/action-add-labels@v1 + # with: + # labels: | + # preview + # dispatch_update_deployment: + # needs: add_preview_label + # runs-on: ubuntu-latest + # if: ${{ contains(github.event.pull_request.labels.*.name, 'deployed') }} + # steps: + # - name: Dispatch Update Preview Repository Command + # uses: myrotvorets/trigger-repository-dispatch-action@1.0.0 + # with: + # token: ${{ secrets.PAT }} + # repo: internxt/environments + # type: update-preview-command + # payload: | + # { + # "github": { + # "payload": { + # "repository": { + # "name": "${{ github.event.repository.name }}", + # "full_name": "${{ github.event.repository.full_name }}" + # }, + # "issue": { + # "number": ${{ github.event.number }}, + # "labels": ${{ toJSON(github.event.pull_request.labels) }} + # } + # } + # }, + # "slash_command": { + # "args": { + # "named": { + # "deployment": "${{ github.event.repository.name }}", + # "tag": "preview-${{ github.event.number }}-${{ github.event.pull_request.head.sha }}", + # "imageSuffix": "-dev" + # } + # } + # } + # } + # dispatch_check_deployment: + # needs: add_preview_label + # runs-on: ubuntu-latest + # steps: + # - name: Dispatch Check Preview Repository Command + # uses: myrotvorets/trigger-repository-dispatch-action@1.0.0 + # with: + # token: ${{ secrets.PAT }} + # repo: internxt/environments + # type: check-preview-command + # payload: | + # { + # "github": { + # "payload": { + # "repository": { + # "name": "${{ github.event.repository.name }}", + # "full_name": "${{ github.event.repository.full_name }}", + # "html_url": "${{ github.event.repository.html_url }}" + # }, + # "issue": { + # "number": ${{ github.event.number }}, + # "labels": ${{ toJSON(github.event.pull_request.labels) }}, + # "pull_request": { + # "html_url": "${{ github.event.pull_request.html_url }}" + # } + # } + # } + # }, + # "slash_command": { + # "args": { + # "named": { + # "notify": "true" + # } + # } + # } + # } \ No newline at end of file diff --git a/src/app/middleware/feature-limits.middleware.ts b/src/app/middleware/feature-limits.middleware.ts new file mode 100644 index 00000000..ceda4ae9 --- /dev/null +++ b/src/app/middleware/feature-limits.middleware.ts @@ -0,0 +1,85 @@ +import { Request, Response, NextFunction } from 'express'; +import { AuthorizedUser } from '../routes/types'; +import { MissingValuesForFeatureLimit, NoLimitFoundForUserTierAndLabel } from '../services/errors/FeatureLimitsErrors'; +import Logger from '../../lib/logger'; + +const logger = Logger.getInstance(); + +type User = AuthorizedUser['user']; +type Middleware = (req: Request & { behalfUser?: User }, res: Response, next: NextFunction) => Promise; +type DataSource = { fieldName: string; sourceKey: string }; +type BuilderArgs = { + limitLabel: string; + dataSources: DataSource[]; +}; + +const LimitLabels = { + MaxFileUploadSize: 'max-file-upload-size', +}; + +const build = (Service: { + FeatureLimits: { + shouldLimitBeEnforced: (user: User, limitLabel: string, data: any) => Promise; + }; +}) => { + const mdBuilder = ({ limitLabel, dataSources }: BuilderArgs) => + (async (req, res, next) => { + try { + const user = (req as any).behalfUser || (req as AuthorizedUser).user; + + if (!user || !user.tierId) { + return next(); + } + + try { + const extractedData = extractDataFromRequest(req, dataSources); + const shouldLimitBeEnforced = await Service.FeatureLimits.shouldLimitBeEnforced( + user, + limitLabel, + extractedData, + ); + + if (shouldLimitBeEnforced) { + return res.status(402).send('You reached the limit for your tier!'); + } + } catch (err) { + if (err instanceof MissingValuesForFeatureLimit) { + return res.status(400).send(err.message); + } + + if (err instanceof NoLimitFoundForUserTierAndLabel) { + logger.error('[FEATURE_LIMIT]: Error getting user limit, bypassing it userUuid: %s', user.uuid); + next(); + } + + logger.error('[FEATURE_LIMIT]: Unexpected error ', err); + return res.status(400).send('Internal Server error'); + } + + next(); + } catch (err) { + next(err); + } + }) as Middleware; + + return { + UploadFile: mdBuilder({ + limitLabel: LimitLabels.MaxFileUploadSize, + dataSources: [{ sourceKey: 'body', fieldName: 'file' }], + }), + }; +}; + +const extractDataFromRequest = (request: any, dataSources: DataSource[]) => { + const extractedData = {} as any; + for (const { sourceKey, fieldName } of dataSources) { + const value = request[sourceKey][fieldName]; + if (value === null || value === undefined) { + throw new MissingValuesForFeatureLimit(`Missing required value to check user limits, ${fieldName} is missing`); + } + extractedData[fieldName] = value; + } + return extractedData; +}; + +export { build, LimitLabels }; diff --git a/src/app/models/index.ts b/src/app/models/index.ts index c95806d3..fa449b46 100644 --- a/src/app/models/index.ts +++ b/src/app/models/index.ts @@ -176,7 +176,6 @@ export default (database: Sequelize) => { Limit.belongsToMany(Tier, { through: TierLimit, - as: 'tiers', }); return { diff --git a/src/app/routes/gateway.js b/src/app/routes/gateway.js index 34b3b234..2f4771a2 100644 --- a/src/app/routes/gateway.js +++ b/src/app/routes/gateway.js @@ -79,17 +79,21 @@ module.exports = (Router, Service) => { let user = await Service.User.FindUserByUuid(uuid); if (!user) { - Logger.error('[Gateway]: Failed to get user :%s', uuid); + Logger.error('[GATEWAY/TIER]: Failed to get user :%s', uuid); return res.status(500).send(); } - const paidPlanTier = await Service.FeatureLimits.getTierByPlanId( + let paidPlanTier = await Service.FeatureLimits.getTierByPlanId( planId === 'free_individual_tier' ? INDIVIDUAL_FREE_TIER_PLAN_ID : planId, ); + if (!paidPlanTier) { - Logger.error(`[GATEWAY]: Plan id not found id: ${planId} email: ${user.email}`); - return res.status(500).send({ error: 'Plan was not found' }); + Logger.error( + `[GATEWAY/TIER]: Plan id not found, assigning free tier by default. id: ${planId}, email: ${user.email}`, + ); + paidPlanTier = await Service.FeatureLimits.getIndividualFreeTier(); } + await Service.User.updateTier(user, paidPlanTier.tierId); return res.status(200).send({ error: null, user: { ...user.dataValues, tierId: paidPlanTier.tierId } }); }); diff --git a/src/app/routes/storage.ts b/src/app/routes/storage.ts index 9cc29b7b..ce42e66d 100644 --- a/src/app/routes/storage.ts +++ b/src/app/routes/storage.ts @@ -21,6 +21,7 @@ import { FolderWithNameAlreadyExistsError } from '../services/errors/FolderWithNameAlreadyExistsError'; import * as resourceSharingMiddlewareBuilder from '../middleware/resource-sharing.middleware'; +import { build as featureLimitsMiddlewareBuilder, LimitLabels } from '../middleware/feature-limits.middleware'; import {validate } from 'uuid'; type AuthorizedRequest = Request & { user: UserAttributes }; @@ -30,6 +31,7 @@ interface Services { Folder: any; UsersReferrals: any; Analytics: any; + FeatureLimits: any; User: any; Notifications: any; Share: any; @@ -105,6 +107,38 @@ export class StorageController { } } + public async checkFileSizeLimit(req: Request, res: Response) { + const { behalfUser } = req as SharedRequest; + const { file } = req.body; + try { + if (!behalfUser.tierId) { + return res.status(404).json({ error: 'User with no limit' }); + } + if (!file || file.size === undefined || file.size === null) { + this.logger.error( + `Invalid metadata for file limit check ${behalfUser.email}: ${JSON.stringify(file, null, 2)}`, + ); + return res.status(400).json({ error: 'Invalid metadata for limit check' }); + } + const shouldLimitBeEnforced = await this.services.FeatureLimits.shouldLimitBeEnforced( + behalfUser, + LimitLabels.MaxFileUploadSize, + { file }, + ); + + if (shouldLimitBeEnforced) { + return res.status(402).send('This file size exceeds the limit for your tier!'); + } + return res.status(200).send('File can be upload'); + } catch (err) { + this.logger.error( + `[FEATURE_LIMIT] ERROR: ${(err as Error).message}, BODY ${JSON.stringify(file)}, STACK: ${(err as Error).stack + } USER: ${behalfUser.email}`, + ); + res.status(500).send({ error: 'Internal Server Error' }); + } + } + public async checkFileExistence(req: Request, res: Response) { const { behalfUser } = req as SharedRequest; const { file } = req.body as { file: { name: string; folderId: number; type: string } }; @@ -811,14 +845,24 @@ export default (router: Router, service: any) => { const sharedAdapter = sharedMiddlewareBuilder.build(service); const teamsAdapter = teamsMiddlewareBuilder.build(service); const resourceSharingAdapter = resourceSharingMiddlewareBuilder.build(service); + const featureLimitsAdapter = featureLimitsMiddlewareBuilder(service); const controller = new StorageController(service, Logger); router.post('/storage/file', passportAuth, sharedAdapter, resourceSharingAdapter.UploadFile, + featureLimitsAdapter.UploadFile, controller.createFile.bind(controller) ); + + router.post('/storage/file/check-limit', + passportAuth, + sharedAdapter, + resourceSharingAdapter.UploadFile, + controller.checkFileSizeLimit.bind(controller) + ); + router.post('/storage/file/exists', passportAuth, sharedAdapter, diff --git a/src/app/services/errors/FeatureLimitsErrors.ts b/src/app/services/errors/FeatureLimitsErrors.ts new file mode 100644 index 00000000..8d694289 --- /dev/null +++ b/src/app/services/errors/FeatureLimitsErrors.ts @@ -0,0 +1,15 @@ +export class NoLimitFoundForUserTierAndLabel extends Error { + constructor(message: string) { + super(message); + + Object.setPrototypeOf(this, NoLimitFoundForUserTierAndLabel.prototype); + } +} + +export class MissingValuesForFeatureLimit extends Error { + constructor(message: string) { + super(message); + + Object.setPrototypeOf(this, MissingValuesForFeatureLimit.prototype); + } +} diff --git a/src/app/services/featureLimit.js b/src/app/services/featureLimit.js index ba18fa55..e83256b4 100644 --- a/src/app/services/featureLimit.js +++ b/src/app/services/featureLimit.js @@ -1,3 +1,11 @@ +const { LimitLabels } = require('../middleware/feature-limits.middleware'); +const { NoLimitFoundForUserTierAndLabel } = require('./errors/FeatureLimitsErrors'); +const { INDIVIDUAL_FREE_TIER_PLAN_ID } = require('../constants'); + +const LimitTypes = { + Counter: 'counter', + Boolean: 'boolean', +}; module.exports = (Model, App) => { const getTierByPlanId = async (planId) => { @@ -7,8 +15,65 @@ module.exports = (Model, App) => { }); }; + const getIndividualFreeTier = async () => { + return getTierByPlanId(INDIVIDUAL_FREE_TIER_PLAN_ID); + }; + + const findLimitByLabelAndTier = async (tierId, label) => { + return Model.limits.findOne({ + where: { + label, + }, + include: [ + { + model: Model.tiers, + where: { + id: tierId, + }, + }, + ], + }); + }; + + const isBooleanLimitNotAvailable = (limit) => { + return limit.type === LimitTypes.Boolean && limit.value !== 'true'; + }; + + const shouldLimitBeEnforced = async (user, limitLabel, data) => { + const limit = await findLimitByLabelAndTier(user.tierId, limitLabel); + + if (!limit) { + throw new NoLimitFoundForUserTierAndLabel('No limit found for this user tier and label!'); + } + if (limit.type === LimitTypes.Boolean) { + return isBooleanLimitNotAvailable(limit); + } + + const isLimitSuprassed = await checkCounterLimit(user, limit, data); + + return isLimitSuprassed; + }; + + const checkCounterLimit = (user, limit, data) => { + switch (limit.label) { + case LimitLabels.MaxFileUploadSize: + return isMaxFileSizeLimitSurprassed({ limit, data }); + default: + return false; + } + }; + + const isMaxFileSizeLimitSurprassed = async ({ limit, data }) => { + const { + file: { size }, + } = data; + return Number(limit.value) < size / 1024 / 1024 / 1024; + }; + return { Name: 'FeatureLimits', getTierByPlanId, + shouldLimitBeEnforced, + getIndividualFreeTier, }; };