From fe956df9082e84caeded4b2f2be9056785126e75 Mon Sep 17 00:00:00 2001 From: Serhii Ofii <132130496+SnowGlowedMountain@users.noreply.github.com> Date: Wed, 13 Nov 2024 22:24:22 +1300 Subject: [PATCH] Serhii milestone4 (#69) * fix:10.21 * chroe:lint * fix:clean_up_apis * add:page_index * chrome-extension --- frontend/src/App.tsx | 10 +++ frontend/src/components/nav/Navbar.tsx | 1 + frontend/src/gen/api.ts | 50 ++++++++++++ frontend/src/pages/Apikey.tsx | 102 +++++++++++++++++++++++++ linguaphoto/api/api.py | 3 +- linguaphoto/api/apikey.py | 24 ++++++ linguaphoto/api/collection.py | 12 ++- linguaphoto/api/image.py | 24 +++++- linguaphoto/crud/user.py | 25 ++++++ linguaphoto/models.py | 3 +- linguaphoto/schemas/user.py | 1 + linguaphoto/utils/auth.py | 20 +++++ 12 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/Apikey.tsx create mode 100644 linguaphoto/api/apikey.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 13c2c11..09a3847 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { LoadingProvider } from "contexts/LoadingContext"; import { SocketProvider } from "contexts/SocketContext"; import { AlertQueue, AlertQueueProvider } from "hooks/alerts"; import { ThemeProvider } from "hooks/theme"; +import APIKeyPage from "pages/Apikey"; import CollectionPage from "pages/Collection"; import Collections from "pages/Collections"; import Home from "pages/Home"; @@ -71,6 +72,15 @@ const App = () => { /> } /> + } + requiredSubscription={true} // Set true if subscription is required for this route + /> + } + /> } /> { const navItems = [ { name: "My Collections", path: "/collections", isExternal: false }, { name: "Subscription", path: "/subscription", isExternal: false }, + { name: "API key", path: "/api-key", isExternal: false }, ]; return ( diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index 1bbf655..5fadaff 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -206,6 +206,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api-key/generate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["generate_api_key"]; + put?: never; + /** Login User */ + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/user/me": { parameters: { query?: never; @@ -401,6 +418,9 @@ export interface components { success: boolean; error: string; }; + APIkeyResponse: { + api_key: string; + }; /** GithubAuthResponse */ GithubAuthResponse: { /** Api Key */ @@ -690,6 +710,7 @@ export interface components { email: string; is_subscription: boolean; is_auth: boolean; + api_key: string; }; /** * UserPublic @@ -1375,6 +1396,35 @@ export interface operations { }; }; }; + generate_api_key: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIkeyResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_signup_token_email_signup_get__id__get: { parameters: { query?: never; diff --git a/frontend/src/pages/Apikey.tsx b/frontend/src/pages/Apikey.tsx new file mode 100644 index 0000000..b7937b1 --- /dev/null +++ b/frontend/src/pages/Apikey.tsx @@ -0,0 +1,102 @@ +import { useAuth } from "contexts/AuthContext"; +import { useAlertQueue } from "hooks/alerts"; +import React, { useEffect, useState } from "react"; + +const ApiKeyManager: React.FC = () => { + const [apiKey, setApiKey] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [copied, setCopied] = useState(false); + const { client, auth } = useAuth(); + const { addAlert } = useAlertQueue(); + // Simulate API call to generate or regenerate a new API key + useEffect(() => { + if (auth?.api_key) setApiKey(auth.api_key); + }, [auth]); + const generateApiKey = async () => { + setIsLoading(true); + setCopied(false); // Reset copy state + try { + // Replace this with your actual API call + const { data, error } = await client.GET("/api-key/generate"); + if (error) addAlert(error.detail?.toString(), "error"); + else setApiKey(data.api_key); + } catch (error) { + console.error("Error generating API key:", error); + } finally { + setIsLoading(false); + } + }; + // Copy the API key to the clipboard + const copyToClipboard = () => { + if (apiKey) { + navigator.clipboard.writeText(apiKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); // Reset copied state after 2 seconds + } + }; + + // Mask part of the API key for security + const getMaskedApiKey = (key: string) => { + if (key.length <= 8) return key; // Return as-is if too short to mask + return `${key.slice(0, 14)}..........${key.slice(-4)}`; + }; + + return ( + + + API Key + + Please retain the API key, as it's essential for enabling image + uploads via our browser extension. + + {apiKey ? ( + + + {getMaskedApiKey(apiKey)} + + + {copied ? "Copied!" : "Copy"} + + + {isLoading + ? "Generating..." + : apiKey + ? "Regenerate API Key" + : "Generate API Key"} + + + ) : ( + <> + + {isLoading + ? "Generating..." + : apiKey + ? "Regenerate API Key" + : "Generate API Key"} + + No API key generated yet. + > + )} + + + ); +}; + +export default ApiKeyManager; diff --git a/linguaphoto/api/api.py b/linguaphoto/api/api.py index fa5e746..aad636d 100644 --- a/linguaphoto/api/api.py +++ b/linguaphoto/api/api.py @@ -14,7 +14,7 @@ from fastapi import APIRouter -from linguaphoto.api import collection, image, subscription, user +from linguaphoto.api import apikey, collection, image, subscription, user # Create a new API router router = APIRouter() @@ -24,6 +24,7 @@ router.include_router(collection.router, prefix="/collection") router.include_router(image.router, prefix="/image") router.include_router(subscription.router, prefix="/subscription") +router.include_router(apikey.router, prefix="/api-key") # Define a root endpoint that returns a simple message diff --git a/linguaphoto/api/apikey.py b/linguaphoto/api/apikey.py new file mode 100644 index 0000000..3889f05 --- /dev/null +++ b/linguaphoto/api/apikey.py @@ -0,0 +1,24 @@ +"""Collection API.""" + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from linguaphoto.crud.user import UserCrud +from linguaphoto.utils.auth import get_current_user_id, subscription_validate + +router = APIRouter() + + +class ApiKeyResponse(BaseModel): + api_key: str + + +@router.get("/generate", response_model=ApiKeyResponse) +async def generate( + user_id: str = Depends(get_current_user_id), + user_crud: UserCrud = Depends(), + is_subscribed: bool = Depends(subscription_validate), +) -> ApiKeyResponse: + async with user_crud: + new_key = await user_crud.generate_api_key(user_id) + return ApiKeyResponse(api_key=new_key) diff --git a/linguaphoto/api/collection.py b/linguaphoto/api/collection.py index 3609895..756c541 100644 --- a/linguaphoto/api/collection.py +++ b/linguaphoto/api/collection.py @@ -12,7 +12,7 @@ CollectionPublishFragment, FeaturedImageFragnment, ) -from linguaphoto.utils.auth import get_current_user_id +from linguaphoto.utils.auth import get_current_user_id, get_current_user_id_by_api_key router = APIRouter() @@ -54,6 +54,16 @@ async def getcollections( return collections +@router.get("/get_all_api_key", response_model=List[Collection]) +async def getcollection_api_key( + user_id: str = Depends(get_current_user_id_by_api_key), collection_crud: CollectionCrud = Depends() +) -> List[Collection]: + print(user_id) + async with collection_crud: + collections = await collection_crud.get_collections(user_id=user_id) + return collections + + @router.post("/edit") async def editcollection( collection: CollectionEditFragment, diff --git a/linguaphoto/api/image.py b/linguaphoto/api/image.py index 8acf940..2284388 100644 --- a/linguaphoto/api/image.py +++ b/linguaphoto/api/image.py @@ -10,7 +10,12 @@ from linguaphoto.models import Image from linguaphoto.schemas.image import ImageTranslateFragment from linguaphoto.socket_manager import notify_user -from linguaphoto.utils.auth import get_current_user_id, subscription_validate +from linguaphoto.utils.auth import ( + get_current_user_id, + get_current_user_id_by_api_key, + subscription_validate, + subscription_validate_by_api_key, +) router = APIRouter() translating_images: List[str] = [] @@ -42,6 +47,23 @@ async def upload_image( return image +@router.post("/upload_by_api_key", response_model=Image) +async def upload_image_by_api_key( + file: UploadFile = File(...), + id: Annotated[str, Form()] = "", + user_id: str = Depends(get_current_user_id_by_api_key), + is_subscribed: bool = Depends(subscription_validate_by_api_key), + image_crud: ImageCrud = Depends(), +) -> Image: + """Upload Image and create new Image.""" + async with image_crud: + image = await image_crud.create_image(file, user_id, id) + if image: + # Run translate in the background + asyncio.create_task(translate_background(image.id, image_crud, user_id)) + return image + + @router.get("/get_all", response_model=List[Image]) async def get_images(collection_id: str, image_crud: ImageCrud = Depends()) -> List[Image]: async with image_crud: diff --git a/linguaphoto/crud/user.py b/linguaphoto/crud/user.py index f7a3e11..87cca14 100644 --- a/linguaphoto/crud/user.py +++ b/linguaphoto/crud/user.py @@ -1,5 +1,7 @@ """Defines CRUD interface for user API.""" +import random +import string from typing import List from linguaphoto.crud.base import BaseCrud @@ -7,6 +9,13 @@ from linguaphoto.schemas.user import UserSigninFragment, UserSignupFragment +def generate_api_key() -> str: + # Generate a random API key (example: sk-abc123def456) + prefix = "lingua-sk-" + key = "".join(random.choices(string.ascii_lowercase + string.digits, k=16)) + return f"{prefix}{key}" + + class UserCrud(BaseCrud): async def create_user_from_email(self, user: UserSignupFragment) -> User | None: duplicated_user = await self._get_items_from_secondary_index("email", user.email, User) @@ -23,6 +32,17 @@ async def get_user_by_email(self, email: str) -> User | None: res = await self._get_items_from_secondary_index("email", email, User) return res[0] + async def get_user_by_api_key(self, api_key: str) -> User | None: + res = await self._list_items( + item_class=User, + filter_expression="#api_key=:api_key", + expression_attribute_names={"#api_key": "api_key"}, + expression_attribute_values={":api_key": api_key}, + ) + if res: + return res[0] + return None + async def verify_user_by_email(self, user: UserSigninFragment) -> bool: users: List[User] = await self._get_items_from_secondary_index("email", user.email, User) # Access the first user in the list and verify the password @@ -34,3 +54,8 @@ async def verify_user_by_email(self, user: UserSigninFragment) -> bool: async def update_user(self, id: str, data: dict) -> None: await self._update_item(id, User, data) + + async def generate_api_key(self, id: str) -> str: + new_key = generate_api_key() + await self._update_item(id, User, {"api_key": new_key}) + return new_key diff --git a/linguaphoto/models.py b/linguaphoto/models.py index 4d2693c..f7aff6e 100644 --- a/linguaphoto/models.py +++ b/linguaphoto/models.py @@ -1,6 +1,6 @@ """Models!""" -from typing import List, Self +from typing import List, Optional, Self from uuid import uuid4 from bcrypt import checkpw, gensalt, hashpw @@ -33,6 +33,7 @@ class User(LinguaBaseModel): email: str password_hash: str is_subscription: bool + api_key: Optional[str] = None @classmethod def create(cls, user: UserSignupFragment) -> Self: diff --git a/linguaphoto/schemas/user.py b/linguaphoto/schemas/user.py index bd7ccf7..73d0735 100644 --- a/linguaphoto/schemas/user.py +++ b/linguaphoto/schemas/user.py @@ -29,3 +29,4 @@ class UserSigninRespondFragment(BaseModel): email: EmailStr is_subscription: bool is_auth: bool + api_key: str diff --git a/linguaphoto/utils/auth.py b/linguaphoto/utils/auth.py index 108f37e..7b61bf8 100644 --- a/linguaphoto/utils/auth.py +++ b/linguaphoto/utils/auth.py @@ -55,6 +55,15 @@ async def get_current_user_id(token: str = Depends(oauth2_schema)) -> str: return user_id +# Dependency to get token and compare with api_key +async def get_current_user_id_by_api_key(api_key: str = Depends(oauth2_schema), user_crud: UserCrud = Depends()) -> str: + async with user_crud: + user = await user_crud.get_user_by_api_key(api_key) + if user is None: + raise HTTPException(status_code=422, detail="Could not validate credentials") + return user.id + + async def subscription_validate(token: str = Depends(oauth2_schema), user_crud: UserCrud = Depends()) -> bool: user_id = decode_access_token(token) if user_id is None: @@ -66,3 +75,14 @@ async def subscription_validate(token: str = Depends(oauth2_schema), user_crud: if user.is_subscription is False: raise HTTPException(status_code=422, detail="You need to subscribe.") return True + + +async def subscription_validate_by_api_key( + api_key: str = Depends(oauth2_schema), user_crud: UserCrud = Depends() +) -> bool: + user = await user_crud.get_user_by_api_key(api_key) + if user is None: + raise HTTPException(status_code=422, detail="Could not validate credentials") + if user.is_subscription is False: + raise HTTPException(status_code=422, detail="You need to subscribe.") + return True
+ Please retain the API key, as it's essential for enabling image + uploads via our browser extension. +
+ {getMaskedApiKey(apiKey)} +
No API key generated yet.