Feat/rate limiting (#719)

* feat: add max brain count

* fix: prevent page cashing when invitation is invalid

* feat: rename rights to role in frontend
This commit is contained in:
Mamadou DICKO 2023-07-20 18:17:55 +02:00 committed by GitHub
parent b3455d3686
commit d27504f735
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 215 additions and 73 deletions

View File

@ -6,8 +6,10 @@ JWT_SECRET_KEY=<change-me>
AUTHENTICATE=true
GOOGLE_APPLICATION_CREDENTIALS=<change-me>
GOOGLE_CLOUD_PROJECT=<change-me>
MAX_BRAIN_SIZE=52428800
MAX_REQUESTS_NUMBER=200
MAX_BRAIN_PER_USER=5
#Private LLM Variables
PRIVATE=False

View File

@ -1,4 +1,3 @@
import os
from typing import Any, List, Optional
from uuid import UUID
@ -6,7 +5,7 @@ from logger import get_logger
from pydantic import BaseModel
from utils.vectors import get_unique_files_from_vector_ids
from models.settings import CommonsDep, common_dependencies
from models.settings import BrainRateLimiting, CommonsDep, common_dependencies
from models.users import User
logger = get_logger(__name__)
@ -19,12 +18,16 @@ class Brain(BaseModel):
model: Optional[str] = "gpt-3.5-turbo-0613"
temperature: Optional[float] = 0.0
max_tokens: Optional[int] = 256
max_brain_size: Optional[int] = int(os.getenv("MAX_BRAIN_SIZE", 52428800))
files: List[Any] = []
class Config:
arbitrary_types_allowed = True
@property
def max_brain_size(self) -> int:
brain_rate_limiting = BrainRateLimiting()
return brain_rate_limiting.max_brain_size
@property
def commons(self) -> CommonsDep:
return common_dependencies()

View File

@ -7,6 +7,11 @@ from supabase.client import Client, create_client
from vectorstore.supabase import SupabaseVectorStore
class BrainRateLimiting(BaseSettings):
max_brain_size = 52428800
max_brain_per_user = 5
class BrainSettings(BaseSettings):
openai_api_key: str
anthropic_api_key: str

View File

@ -8,7 +8,7 @@ from models.brains import (
get_default_user_brain,
get_default_user_brain_or_create_new,
)
from models.settings import common_dependencies
from models.settings import BrainRateLimiting, common_dependencies
from models.users import User
from routes.authorizations.brain_authorization import has_brain_authorization
@ -104,6 +104,15 @@ async def create_brain_endpoint(
brain = Brain(name=brain.name) # pyright: ignore reportPrivateUsage=none
user_brains = brain.get_user_brains(current_user.id)
max_brain_per_user = BrainRateLimiting().max_brain_per_user
if len(user_brains) >= max_brain_per_user:
raise HTTPException(
status_code=429,
detail=f"Maximum number of brains reached ({max_brain_per_user}).",
)
brain.create_brain() # pyright: ignore reportPrivateUsage=none
default_brain = get_default_user_brain(current_user)
if default_brain:

View File

@ -4,6 +4,7 @@ import time
from auth import AuthBearer, get_current_user
from fastapi import APIRouter, Depends, Request
from models.brains import Brain, get_default_user_brain
from models.settings import BrainRateLimiting
from models.users import User
user_router = APIRouter()
@ -32,7 +33,8 @@ async def get_user_endpoint(
information about the user's API usage.
"""
max_brain_size = int(os.getenv("MAX_BRAIN_SIZE", 52428800))
max_brain_size = BrainRateLimiting().max_brain_size
if request.headers.get("Openai-Api-Key"):
max_brain_size = MAX_BRAIN_SIZE_WITH_OWN_KEY

View File

@ -34,7 +34,7 @@ const DocumentItem = forwardRef(
const { track } = useEventTracking();
const { currentBrain } = useBrainContext();
const canDeleteFile = currentBrain?.rights === "Owner";
const canDeleteFile = currentBrain?.role === "Owner";
if (!session) {
throw new Error("User session not found");

View File

@ -17,7 +17,7 @@ export const useInvitation = () => {
const brainId = params?.brainId as UUID | undefined;
const [isLoading, setIsLoading] = useState(false);
const [brainName, setBrainName] = useState<string>("");
const [rights, setRights] = useState<BrainRoleType | undefined>();
const [role, setRole] = useState<BrainRoleType | undefined>();
const [isProcessingRequest, setIsProcessingRequest] = useState(false);
const { publish } = useToast();
@ -37,9 +37,9 @@ export const useInvitation = () => {
const checkInvitationValidity = async () => {
try {
const { name, rights: assignedRights } = await getInvitation(brainId);
const { name, role: assignedRole } = await getInvitation(brainId);
setBrainName(name);
setRights(assignedRights);
setRole(assignedRole);
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
publish({
@ -130,7 +130,7 @@ export const useInvitation = () => {
handleAccept,
handleDecline,
brainName,
rights,
role,
isLoading,
isProcessingRequest,
};

View File

@ -15,7 +15,7 @@ const InvitationPage = (): JSX.Element => {
handleDecline,
isLoading,
brainName,
rights,
role,
} = useInvitation();
const { session } = useSupabase();
@ -27,15 +27,18 @@ const InvitationPage = (): JSX.Element => {
redirectToLogin();
}
if (rights === undefined) {
throw new Error("Rights are undefined");
if (role === undefined) {
// This should never happen
// It is a way to prevent the page from crashing when invitation is invalid instead of throwing an error
// The user will be redirected to the home page (handled in the useInvitation hook)
return <div />;
}
return (
<main className="pt-10">
<PageHeading
title={`Welcome to ${brainName}!`}
subtitle={`You have been invited to join this brain as a ${rights} and start exploring. Do you accept this exciting journey?`}
subtitle={`You have been invited to join this brain as a ${role} and start exploring. Do you accept this exciting journey?`}
/>
{isProcessingRequest ? (
<div className="flex flex-col items-center justify-center mt-5">

View File

@ -35,7 +35,7 @@ const UploadPage = (): JSX.Element => {
);
}
const hasUploadRights = requiredRolesForUpload.includes(currentBrain.rights);
const hasUploadRights = requiredRolesForUpload.includes(currentBrain.role);
if (!hasUploadRights) {
return (
@ -44,7 +44,7 @@ const UploadPage = (): JSX.Element => {
<strong className="font-bold mr-1">Oh no!</strong>
<span className="block sm:inline">
{
"You don't have the necessary rights to upload content to the selected brain. 🧠💡🥲"
"You don't have the necessary role to upload content to the selected brain. 🧠💡🥲"
}
</span>
</div>

View File

@ -2,7 +2,8 @@
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Subscription, SubscriptionUpdatableProperties } from "../brain";
import { Subscription } from "../brain";
import { SubscriptionUpdatableProperties } from "../types";
import { useBrainApi } from "../useBrainApi";
const axiosGetMock = vi.fn(() => ({
@ -89,6 +90,12 @@ describe("useBrainApi", () => {
});
it("should call getBrains with the correct parameters", async () => {
axiosGetMock.mockImplementationOnce(() => ({
data: {
//@ts-ignore we don't really need returned value here
brains: [],
},
}));
const {
result: {
current: { getBrains },
@ -123,19 +130,26 @@ describe("useBrainApi", () => {
const subscriptions: Subscription[] = [
{
email: "user@quivr.app",
rights: "Viewer",
role: "Viewer",
},
];
await addBrainSubscriptions(id, subscriptions);
expect(axiosPostMock).toHaveBeenCalledTimes(1);
expect(axiosPostMock).toHaveBeenCalledWith(
`/brains/${id}/subscription`,
subscriptions
);
expect(axiosPostMock).toHaveBeenCalledWith(`/brains/${id}/subscription`, [
{
email: "user@quivr.app",
rights: "Viewer",
},
]);
});
it("should call getBrainUsers with the correct parameters", async () => {
axiosGetMock.mockImplementationOnce(() => ({
//@ts-ignore we don't really need returned value here
data: [],
}));
const {
result: {
current: { getBrainUsers },
@ -156,13 +170,13 @@ describe("useBrainApi", () => {
const brainId = "123";
const email = "456";
const subscription: SubscriptionUpdatableProperties = {
rights: "Viewer",
role: "Viewer",
};
await updateBrainAccess(brainId, email, subscription);
expect(axiosPutMock).toHaveBeenCalledTimes(1);
expect(axiosPutMock).toHaveBeenCalledWith(
`/brains/${brainId}/subscription`,
{ ...subscription, email }
{ rights: "Viewer", email }
);
});
});

View File

@ -1,9 +1,22 @@
/* eslint-disable max-lines */
import { AxiosInstance } from "axios";
import { BrainRoleType } from "@/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/types";
import { Brain, MinimalBrainForUser } from "@/lib/context/BrainProvider/types";
import {
BackendMinimalBrainForUser,
Brain,
MinimalBrainForUser,
} from "@/lib/context/BrainProvider/types";
import { Document } from "@/lib/types/Document";
import { SubscriptionUpdatableProperties } from "./types";
import { mapBackendMinimalBrainToMinimalBrain } from "./utils/mapBackendMinimalBrainToMinimalBrain";
import {
BackendSubscription,
mapSubscriptionToBackendSubscription,
} from "./utils/mapSubscriptionToBackendSubscription";
import { mapSubscriptionUpdatablePropertiesToBackendSubscriptionUpdatableProperties } from "./utils/mapSubscriptionUpdatablePropertiesToBackendSubscriptionUpdatableProperties";
export const getBrainDocuments = async (
brainId: string,
axiosInstance: AxiosInstance
@ -19,11 +32,10 @@ export const createBrain = async (
name: string,
axiosInstance: AxiosInstance
): Promise<MinimalBrainForUser> => {
const createdBrain = (
await axiosInstance.post<MinimalBrainForUser>(`/brains/`, { name })
).data;
return createdBrain;
return mapBackendMinimalBrainToMinimalBrain(
(await axiosInstance.post<BackendMinimalBrainForUser>(`/brains/`, { name }))
.data
);
};
export const getBrain = async (
@ -47,40 +59,49 @@ export const deleteBrain = async (
export const getDefaultBrain = async (
axiosInstance: AxiosInstance
): Promise<MinimalBrainForUser | undefined> => {
return (await axiosInstance.get<MinimalBrainForUser>(`/brains/default/`))
.data;
return mapBackendMinimalBrainToMinimalBrain(
(await axiosInstance.get<BackendMinimalBrainForUser>(`/brains/default/`))
.data
);
};
export const getBrains = async (
axiosInstance: AxiosInstance
): Promise<MinimalBrainForUser[]> => {
const brains = (
await axiosInstance.get<{ brains: MinimalBrainForUser[] }>(`/brains/`)
const { brains } = (
await axiosInstance.get<{ brains: BackendMinimalBrainForUser[] }>(
`/brains/`
)
).data;
return brains.brains;
return brains.map(mapBackendMinimalBrainToMinimalBrain);
};
export type Subscription = { email: string; rights: BrainRoleType };
export type Subscription = { email: string; role: BrainRoleType };
export const addBrainSubscriptions = async (
brainId: string,
subscriptions: Subscription[],
axiosInstance: AxiosInstance
): Promise<void> => {
await axiosInstance.post(`/brains/${brainId}/subscription`, subscriptions);
await axiosInstance.post(
`/brains/${brainId}/subscription`,
subscriptions.map(mapSubscriptionToBackendSubscription)
);
};
export const getBrainUsers = async (
brainId: string,
axiosInstance: AxiosInstance
): Promise<Subscription[]> => {
return (await axiosInstance.get<Subscription[]>(`/brains/${brainId}/users`))
.data;
};
const brainsUsers = (
await axiosInstance.get<BackendSubscription[]>(`/brains/${brainId}/users`)
).data;
export type SubscriptionUpdatableProperties = {
rights: BrainRoleType | null;
return brainsUsers.map((brainUser) => ({
email: brainUser.email,
role: brainUser.rights,
}));
};
export const updateBrainAccess = async (
@ -90,7 +111,9 @@ export const updateBrainAccess = async (
axiosInstance: AxiosInstance
): Promise<void> => {
await axiosInstance.put(`/brains/${brainId}/subscription`, {
...subscription,
...mapSubscriptionUpdatablePropertiesToBackendSubscriptionUpdatableProperties(
subscription
),
email: userEmail,
});
};

View File

@ -0,0 +1,5 @@
import { BrainRoleType } from "@/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/types";
export type SubscriptionUpdatableProperties = {
role: BrainRoleType | null;
};

View File

@ -10,9 +10,9 @@ import {
getBrainUsers,
getDefaultBrain,
Subscription,
SubscriptionUpdatableProperties,
updateBrainAccess,
} from "./brain";
import { SubscriptionUpdatableProperties } from "./types";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useBrainApi = () => {

View File

@ -0,0 +1,12 @@
import {
BackendMinimalBrainForUser,
MinimalBrainForUser,
} from "@/lib/context/BrainProvider/types";
export const mapBackendMinimalBrainToMinimalBrain = (
backendMinimalBrain: BackendMinimalBrainForUser
): MinimalBrainForUser => ({
id: backendMinimalBrain.id,
name: backendMinimalBrain.name,
role: backendMinimalBrain.rights,
});

View File

@ -0,0 +1,12 @@
import { BrainRoleType } from "@/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/types";
import { Subscription } from "../brain";
export type BackendSubscription = { email: string; rights: BrainRoleType };
export const mapSubscriptionToBackendSubscription = (
subscription: Subscription
): BackendSubscription => ({
email: subscription.email,
rights: subscription.role,
});

View File

@ -0,0 +1,13 @@
import { BrainRoleType } from "@/lib/components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/types";
import { SubscriptionUpdatableProperties } from "../types";
type BackendSubscriptionUpdatableProperties = {
rights: BrainRoleType | null;
};
export const mapSubscriptionUpdatablePropertiesToBackendSubscriptionUpdatableProperties =
(
subscriptionUpdatableProperties: SubscriptionUpdatableProperties
): BackendSubscriptionUpdatableProperties => ({
rights: subscriptionUpdatableProperties.role,
});

View File

@ -13,8 +13,6 @@ export const acceptInvitation = async (
)
).data;
console.log("acceptedInvitation", acceptedInvitation);
return acceptedInvitation;
};
@ -33,6 +31,11 @@ export const declineInvitation = async (
export type InvitationBrain = {
name: string;
role: BrainRoleType;
};
//TODO: rename rights to role in Backend and use InvitationBrain instead of BackendInvitationBrain
type BackendInvitationBrain = Omit<InvitationBrain, "role"> & {
rights: BrainRoleType;
};
@ -40,7 +43,14 @@ export const getInvitation = async (
brainId: UUID,
axiosInstance: AxiosInstance
): Promise<InvitationBrain> => {
return (
await axiosInstance.get<InvitationBrain>(`/brains/${brainId}/subscription`)
const invitation = (
await axiosInstance.get<BackendInvitationBrain>(
`/brains/${brainId}/subscription`
)
).data;
return {
name: invitation.name,
role: invitation.rights,
};
};

View File

@ -1,3 +1,4 @@
import axios from "axios";
import { FormEvent, useState } from "react";
import { MdAdd } from "react-icons/md";
@ -5,11 +6,12 @@ import Button from "@/lib/components/ui/Button";
import Field from "@/lib/components/ui/Field";
import { Modal } from "@/lib/components/ui/Modal";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useToast } from "@/lib/hooks";
export const AddBrainModal = (): JSX.Element => {
const [newBrainName, setNewBrainName] = useState("");
const [isPending, setIsPending] = useState(false);
const { publish } = useToast();
const { createBrain } = useBrainContext();
const handleSubmit = async (e: FormEvent) => {
@ -17,10 +19,31 @@ export const AddBrainModal = (): JSX.Element => {
if (newBrainName.trim() === "" || isPending) {
return;
}
setIsPending(true);
await createBrain(newBrainName);
setNewBrainName("");
setIsPending(false);
try {
setIsPending(true);
await createBrain(newBrainName);
setNewBrainName("");
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 429) {
publish({
variant: "danger",
text: `${JSON.stringify(
(
err.response as {
data: { detail: string };
}
).data.detail
)}`,
});
} else {
publish({
variant: "danger",
text: `${JSON.stringify(err)}`,
});
}
} finally {
setIsPending(false);
}
};
return (

View File

@ -12,7 +12,7 @@ const requiredAccessToShareBrain: BrainRoleType[] = ["Owner", "Editor"];
export const BrainActions = ({ brain }: BrainActionsProps): JSX.Element => {
return (
<div className="absolute right-0 flex flex-row">
{requiredAccessToShareBrain.includes(brain.rights) && (
{requiredAccessToShareBrain.includes(brain.role) && (
<ShareBrain brainId={brain.id} name={brain.name} />
)}
<DeleteBrain brainId={brain.id} />

View File

@ -39,7 +39,7 @@ vi.mock("@/lib/context/BrainProvider/hooks/useBrainContext", async () => {
useBrainContext: () => ({
...actual.useBrainContext(),
currentBrain: {
rights: "Editor",
role: "Editor",
},
}),
};

View File

@ -23,7 +23,7 @@ export const BrainUsers = ({ brainId }: BrainUsersProps): JSX.Element => {
<BrainUser
key={subscription.email}
email={subscription.email}
rights={subscription.rights}
role={subscription.role}
brainId={brainId}
fetchBrainUsers={fetchBrainUsers}
/>

View File

@ -11,14 +11,14 @@ import { availableRoles } from "../../../../types";
type BrainUserProps = {
email: string;
rights: BrainRoleType;
role: BrainRoleType;
brainId: string;
fetchBrainUsers: () => Promise<void>;
};
export const BrainUser = ({
email,
rights,
role,
brainId,
fetchBrainUsers,
}: BrainUserProps): JSX.Element => {
@ -30,7 +30,7 @@ export const BrainUser = ({
updateSelectedRole,
} = useBrainUser({
fetchBrainUsers: fetchBrainUsers,
rights,
role,
brainId,
email,
});
@ -62,7 +62,7 @@ export const BrainUser = ({
onChange={(newRole) => void updateSelectedRole(newRole)}
value={selectedRole}
options={availableRoles}
readOnly={currentBrain?.rights !== "Owner" && selectedRole === "Owner"}
readOnly={currentBrain?.role !== "Owner" && selectedRole === "Owner"}
/>
</div>
);

View File

@ -9,7 +9,7 @@ import { BrainRoleType } from "../../../../../../../types";
type UseBrainUserProps = {
fetchBrainUsers: () => Promise<void>;
rights: BrainRoleType;
role: BrainRoleType;
brainId: string;
email: string;
};
@ -17,19 +17,19 @@ type UseBrainUserProps = {
export const useBrainUser = ({
brainId,
fetchBrainUsers,
rights,
role,
email,
}: UseBrainUserProps) => {
const { updateBrainAccess } = useBrainApi();
const { publish } = useToast();
const [selectedRole, setSelectedRole] = useState<BrainRoleType>(rights);
const [selectedRole, setSelectedRole] = useState<BrainRoleType>(role);
const [isRemovingAccess, setIsRemovingAccess] = useState(false);
const { currentBrain } = useBrainContext();
const updateSelectedRole = async (newRole: BrainRoleType) => {
setSelectedRole(newRole);
try {
await updateBrainAccess(brainId, email, {
rights: newRole,
role: newRole,
});
publish({ variant: "success", text: `Updated ${email} to ${newRole}` });
void fetchBrainUsers();
@ -58,7 +58,7 @@ export const useBrainUser = ({
setIsRemovingAccess(true);
try {
await updateBrainAccess(brainId, email, {
rights: null,
role: null,
});
publish({ variant: "success", text: `Removed ${email} from brain` });
void fetchBrainUsers();
@ -82,7 +82,7 @@ export const useBrainUser = ({
setIsRemovingAccess(false);
}
};
const canRemoveAccess = currentBrain?.rights === "Owner";
const canRemoveAccess = currentBrain?.role === "Owner";
return {
isRemovingAccess,

View File

@ -20,7 +20,7 @@ export const UserToInvite = ({
roleAssignation,
}: UserToInviteProps): JSX.Element => {
const [selectedRole, setSelectedRole] = useState<BrainRoleType>(
roleAssignation.rights
roleAssignation.role
);
const [email, setEmail] = useState(roleAssignation.email);
const { currentBrain } = useBrainContext();
@ -33,7 +33,7 @@ export const UserToInvite = ({
onChange({
...roleAssignation,
email,
rights: selectedRole,
role: selectedRole,
});
}, [email, selectedRole]);
@ -60,7 +60,7 @@ export const UserToInvite = ({
<Select
onChange={setSelectedRole}
value={selectedRole}
options={userRoleToAssignableRoles[currentBrain.rights]}
options={userRoleToAssignableRoles[currentBrain.role]}
/>
</div>
);

View File

@ -2,6 +2,7 @@
import axios, { AxiosResponse } from "axios";
import { useState } from "react";
import { Subscription } from "@/lib/api/brain/brain";
import { useBrainApi } from "@/lib/api/brain/useBrainApi";
import { useToast } from "@/lib/hooks";
@ -61,11 +62,11 @@ export const useShareBrain = (brainId: string) => {
const inviteUsers = async (): Promise<void> => {
setSendingInvitation(true);
try {
const inviteUsersPayload = roleAssignations
const inviteUsersPayload: Subscription[] = roleAssignations
.filter(({ email }) => email !== "")
.map((assignation) => ({
email: assignation.email,
rights: assignation.rights,
role: assignation.role,
}));
await addBrainSubscriptions(brainId, inviteUsersPayload);

View File

@ -3,7 +3,7 @@ import { BrainRoleAssignation } from "../../../types";
export const generateBrainAssignation = (): BrainRoleAssignation => {
return {
email: "",
rights: "Viewer",
role: "Viewer",
id: Math.random().toString(),
};
};

View File

@ -4,6 +4,6 @@ export type BrainRoleType = (typeof roles)[number];
export type BrainRoleAssignation = {
email: string;
rights: BrainRoleType;
role: BrainRoleType;
id: string;
};

View File

@ -18,6 +18,11 @@ export type Brain = {
export type MinimalBrainForUser = {
id: UUID;
name: string;
role: BrainRoleType;
};
//TODO: rename rights to role in Backend and use MinimalBrainForUser instead of BackendMinimalBrainForUser
export type BackendMinimalBrainForUser = Omit<MinimalBrainForUser, "role"> & {
rights: BrainRoleType;
};