From e04b704de529ce6f87973320faf890a4ed07952d Mon Sep 17 00:00:00 2001 From: Dennis Chen <41879777+chennisden@users.noreply.github.com> Date: Fri, 14 Jun 2024 16:56:35 -0700 Subject: [PATCH] Image pulled (#130) * upload images * upload images * fixed image upload * added smaller versions of files * image/ui fixes * put images on s3 bucket, set up s3 --------- Co-authored-by: Isaac Light --- CONTRIBUTING.md | 21 ++-- frontend/package-lock.json | 41 ++++++- frontend/package.json | 3 + frontend/src/App.tsx | 2 + frontend/src/components/RobotForm.tsx | 18 +-- frontend/src/components/files/UploadImage.tsx | 103 ++++++++++++++++++ frontend/src/components/files/ViewImage.tsx | 56 ++++++++++ frontend/src/hooks/api.tsx | 39 +++++++ frontend/src/pages/PartDetails.tsx | 8 +- frontend/src/pages/RobotDetails.tsx | 47 ++++---- frontend/src/pages/Robots.tsx | 19 +++- frontend/src/pages/TestImages.tsx | 15 +++ frontend/src/pages/YourRobots.tsx | 19 +++- frontend/tsconfig.json | 2 +- store/app/crud/base.py | 13 +++ store/app/crud/robots.py | 19 +++- store/app/crud/users.py | 2 +- store/app/crypto.py | 2 +- store/app/main.py | 2 + store/app/routers/image.py | 48 ++++++++ store/app/routers/part.py | 6 +- store/app/routers/robot.py | 6 +- store/requirements.txt | 4 +- 23 files changed, 423 insertions(+), 72 deletions(-) create mode 100644 frontend/src/components/files/UploadImage.tsx create mode 100644 frontend/src/components/files/ViewImage.tsx create mode 100644 frontend/src/pages/TestImages.tsx create mode 100644 store/app/routers/image.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9225068b..bd3d5acc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,13 +19,13 @@ To get started developing: ## Database -### DynamoDB +### DynamoDB/S3 -When developing locally, use the `amazon/dynamodb-local` Docker image to run a local instance of DynamoDB: +When developing locally, install the `aws` CLI and use the `localstack/localstack` Docker image to run a local instance of AWS: ```bash -docker pull amazon/dynamodb-local # If you haven't already -docker run --name store-db -d -p 8000:8000 amazon/dynamodb-local # Start the container in the background +docker pull localstack/localstack # If you haven't already +docker run --name store-db -d -p 4566:4566 localstack/localstack # Start the container in the background ``` Then, if you need to kill the database, you can run: @@ -41,6 +41,12 @@ Initialize the test databases by running the creation script: python -m store.app.db create ``` +And initialize the image bucket: + +``` +aws s3api create-bucket --bucket images +``` + #### Admin Panel DynamoDB Admin is a GUI that allows you to visually see your tables and their entries. To install, run @@ -52,7 +58,7 @@ npm i -g dynamodb-admin To run, **source the same environment variables that you use for FastAPI** and then run ```bash -dynamodb-admin +DYNAMO_ENDPOINT=http://127.0.0.1:4566 dynamodb-admin ``` ### Redis @@ -105,7 +111,8 @@ export ROBOLIST_ENVIRONMENT=local export AWS_DEFAULT_REGION='us-east-1' export AWS_ACCESS_KEY_ID=idk export AWS_SECRET_ACCESS_KEY=idk -export AWS_ENDPOINT_URL_DYNAMODB=http://127.0.0.1:8000 +export AWS_ENDPOINT_URL_DYNAMODB=http://127.0.0.1:4566 +export AWS_ENDPOINT_URL_S3=http://127.0.0.1:4566 export REACT_APP_BACKEND_URL=http://127.0.0.1:8080 export ROBOLIST_SMTP_HOST=smtp.gmail.com export ROBOLIST_SMTP_SENDER_EMAIL= @@ -145,7 +152,7 @@ To run code formatting: npm run format ``` -### Environment Variables +### Google Client ID You will need to set `REACT_APP_GOOGLE_CLIENT_ID`. To do this, first create a Google client id (see [this LogRocket post](https://blog.logrocket.com/guide-adding-google-login-react-app/)). Then create a `.env.local` file in the `frontend` directory and add the following line: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3440399f..c9196489 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@types/node": "^16.18.97", "@types/react": "^18.3.2", "@types/react-dom": "^18.3.0", + "browser-image-compression": "^2.0.2", "holderjs": "^2.9.9", "nth-check": ">=2.0.1", "postcss": ">=8.4.31", @@ -24,6 +25,7 @@ "react-scripts": "5.0.1", "react-spring": "^9.7.3", "typescript": "^4.9.5", + "uuid": "^10.0.0", "web-vitals": "^2.1.4" }, "devDependencies": { @@ -39,6 +41,7 @@ "@fortawesome/react-fontawesome": "github:fortawesome/react-fontawesome", "@react-oauth/google": "^0.12.1", "@types/jest": "^29.5.12", + "@types/uuid": "^9.0.8", "axios": "^1.7.2", "babel-eslint": "*", "babel-jest": "^29.7.0", @@ -7089,6 +7092,12 @@ "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true + }, "node_modules/@types/warning": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", @@ -8830,6 +8839,14 @@ "node": ">=8" } }, + "node_modules/browser-image-compression": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz", + "integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==", + "dependencies": { + "uzip": "0.20201231.0" + } + }, "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -25898,6 +25915,14 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", @@ -28008,14 +28033,22 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } }, + "node_modules/uzip": { + "version": "0.20201231.0", + "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", + "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==" + }, "node_modules/v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index ad98adf8..68cd70a1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "@types/node": "^16.18.97", "@types/react": "^18.3.2", "@types/react-dom": "^18.3.0", + "browser-image-compression": "^2.0.2", "holderjs": "^2.9.9", "nth-check": ">=2.0.1", "postcss": ">=8.4.31", @@ -19,6 +20,7 @@ "react-scripts": "5.0.1", "react-spring": "^9.7.3", "typescript": "^4.9.5", + "uuid": "^10.0.0", "web-vitals": "^2.1.4" }, "type": "module", @@ -56,6 +58,7 @@ "@fortawesome/react-fontawesome": "github:fortawesome/react-fontawesome", "@react-oauth/google": "^0.12.1", "@types/jest": "^29.5.12", + "@types/uuid": "^9.0.8", "axios": "^1.7.2", "babel-eslint": "*", "babel-jest": "^29.7.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 370e536a..fb8e7927 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import Register from "pages/Register"; import ResetPassword from "pages/ResetPassword"; import RobotDetails from "pages/RobotDetails"; import Robots from "pages/Robots"; +import TestImages from "pages/TestImages"; import VerifyEmail from "pages/VerifyEmail"; import YourParts from "pages/YourParts"; import YourRobots from "pages/YourRobots"; @@ -62,6 +63,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/RobotForm.tsx b/frontend/src/components/RobotForm.tsx index 0a8d935d..d9f28bee 100644 --- a/frontend/src/components/RobotForm.tsx +++ b/frontend/src/components/RobotForm.tsx @@ -1,6 +1,7 @@ import { Bom, Image, Part } from "hooks/api"; import { ChangeEvent, Dispatch, FormEvent, SetStateAction } from "react"; import { Button, Col, Form, Row } from "react-bootstrap"; +import ImageUploadComponent from "./files/UploadImage"; interface RobotFormProps { title: string; @@ -57,6 +58,12 @@ const RobotForm: React.FC = ({ setImages([...robot_images, { url: "", caption: "" }]); }; + const handleImageUploadSuccess = (url: string, index: number) => { + const newImages = [...robot_images]; + newImages[index].url = url; + setImages(newImages); + }; + const handleRemoveImage = (index: number) => { const newImages = robot_images.filter((_, i) => i !== index); setImages(newImages); @@ -150,15 +157,8 @@ const RobotForm: React.FC = ({ {robot_images.map((image, index) => ( - - handleImageChange(index, e)} - required + handleImageUploadSuccess(url, index)} /> void; +} + +const ImageUploadComponent: React.FC = ({ + onUploadSuccess, +}) => { + const [selectedFile, setSelectedFile] = useState(null); + const [compressedFile, setCompressedFile] = useState(null); + const [uploadStatus, setUploadStatus] = useState(null); + const [fileError, setFileError] = useState(null); + const auth = useAuthentication(); + const auth_api = new api(auth.api); + const MAX_FILE_SIZE = 2 * 1024 * 1024; + const handleFileChange = async ( + event: React.ChangeEvent, + ) => { + if (event.target.files) { + const file = event.target.files[0]; + if (file) { + setUploadStatus(null); + if (file.size > MAX_FILE_SIZE) { + setFileError( + `File size should not exceed ${MAX_FILE_SIZE / 1024 / 1024} MB`, + ); + } else { + const options = { + maxSizeMB: 0.2, // Maximum size in MB + maxWidthOrHeight: 800, // Maximum width or height in pixels + useWebWorker: true, // Use multi-threading for compression + }; + try { + const thecompressedFile = await imageCompression(file, options); + setCompressedFile(thecompressedFile); + } catch (error) { + console.error("Error compressing the image:", error); + setFileError("Error compressing the image"); + } + setSelectedFile(file); + setFileError(null); + } + } else { + setFileError("No file selected"); + } + } + }; + + const handleUpload = async () => { + if (fileError) { + setUploadStatus("Failed to upload file"); + return; + } + if (!selectedFile || !compressedFile) { + setUploadStatus("No file selected"); + return; + } + const formData = new FormData(); + formData.append("file", selectedFile); + const compressedFormData = new FormData(); + compressedFormData.append("file", compressedFile); + try { + const image_id = await auth_api.uploadImage(formData); + onUploadSuccess(image_id); + setUploadStatus("File uploaded successfully"); + } catch (error) { + setUploadStatus("Failed to upload file"); + console.error("Error uploading file:", error); + } + }; + + return ( + +
+ + Select Image + + + {fileError && {fileError}} + + {uploadStatus && ( + + {uploadStatus} + + )} +
+ + ); +}; + +export default ImageUploadComponent; diff --git a/frontend/src/components/files/ViewImage.tsx b/frontend/src/components/files/ViewImage.tsx new file mode 100644 index 00000000..66b6440a --- /dev/null +++ b/frontend/src/components/files/ViewImage.tsx @@ -0,0 +1,56 @@ +import { api } from "hooks/api"; +import { useAuthentication } from "hooks/auth"; +import React, { useEffect, useState } from "react"; + +interface ImageProps { + imageId: string; +} + +const ImageComponent: React.FC = ({ imageId }) => { + const [imageSrc, setImageSrc] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const auth = useAuthentication(); + const auth_api = new api(auth.api); + + useEffect(() => { + const fetchImage = async () => { + try { + const response = await auth_api.getImage(imageId); + const url = URL.createObjectURL(response); + setImageSrc(url); + setLoading(false); + } catch (err) { + setError("Failed to fetch image " + imageId); + setLoading(false); + } + }; + + fetchImage(); + }, [imageId]); + + if (loading) return

Loading...

; + if (error) return

{error}

; + + return ( +
+ {imageSrc && ( + Robot + )} +
+ ); +}; + +export default ImageComponent; diff --git a/frontend/src/hooks/api.tsx b/frontend/src/hooks/api.tsx index 36cdfa4a..88f0f837 100644 --- a/frontend/src/hooks/api.tsx +++ b/frontend/src/hooks/api.tsx @@ -443,4 +443,43 @@ export class api { } } } + public async getImage(imageId: string): Promise { + try { + const response = await this.api.get(`/image/${imageId}/`, { + responseType: "blob", + }); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error("Error fetching image:", error.response?.data); + throw new Error( + error.response?.data?.detail || "Error fetching image " + imageId, + ); + } else { + console.error("Unexpected error:", error); + throw new Error("Unexpected error"); + } + } + } + public async uploadImage(formData: FormData): Promise { + try { + const res = await this.api.post("/image/upload/", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return res.data.id; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error("Error uploading image:", error.response?.data); + throw new Error( + error.response?.data?.detail + "gmama" + formData || + "Error uploading image", + ); + } else { + console.error("Unexpected error:", error); + throw new Error("Unexpected error"); + } + } + } } diff --git a/frontend/src/pages/PartDetails.tsx b/frontend/src/pages/PartDetails.tsx index 56f7216e..4c7bac4e 100644 --- a/frontend/src/pages/PartDetails.tsx +++ b/frontend/src/pages/PartDetails.tsx @@ -30,7 +30,7 @@ const PartDetails = () => { const [userId, setUserId] = useState(null); const { id } = useParams(); const [show, setShow] = useState(false); - const [ownerEmail, setOwnerEmail] = useState(null); + const [ownerUsername, setOwnerUsername] = useState(null); const [part, setPart] = useState(null); const [imageIndex, setImageIndex] = useState(0); const [error, setError] = useState(null); @@ -47,8 +47,8 @@ const PartDetails = () => { try { const partData = await auth_api.getPartById(id); setPart(partData); - const ownerEmail = await auth_api.getUserById(partData.owner); - setOwnerEmail(ownerEmail); + const ownerUsername = await auth_api.getUserById(partData.owner); + setOwnerUsername(ownerUsername); } catch (err) { if (err instanceof Error) { setError(err.message); @@ -127,7 +127,7 @@ const PartDetails = () => {

{part_name}

ID: {id}
- {ownerEmail} + {ownerUsername}

diff --git a/frontend/src/pages/RobotDetails.tsx b/frontend/src/pages/RobotDetails.tsx index eb231c26..323bdf82 100644 --- a/frontend/src/pages/RobotDetails.tsx +++ b/frontend/src/pages/RobotDetails.tsx @@ -1,3 +1,4 @@ +import ImageComponent from "components/files/ViewImage"; import { useAlertQueue } from "hooks/alerts"; import { api, Bom } from "hooks/api"; import { useAuthentication } from "hooks/auth"; @@ -41,7 +42,7 @@ const RobotDetails = () => { const [userId, setUserId] = useState(null); const { id } = useParams(); const [show, setShow] = useState(false); - const [ownerEmail, setOwnerEmail] = useState(null); + const [ownerUsername, setOwnerUsername] = useState(null); const [robot, setRobot] = useState(null); const [parts, setParts] = useState([]); const [imageIndex, setImageIndex] = useState(0); @@ -59,8 +60,8 @@ const RobotDetails = () => { try { const robotData = await auth_api.getRobotById(id); setRobot(robotData); - const ownerEmail = await auth_api.getUserById(robotData.owner); - setOwnerEmail(ownerEmail); + const ownerUsername = await auth_api.getUserById(robotData.owner); + setOwnerUsername(ownerUsername); const parts = robotData.bom.map(async (part) => { return { part_name: (await auth_api.getPartById(part.part_id)).part_name, @@ -154,7 +155,7 @@ const RobotDetails = () => {

{name}

ID: {id}
- {ownerEmail} + {ownerUsername} {((response.height && response.height !== "") || @@ -236,6 +237,10 @@ const RobotDetails = () => { data-bs-theme="dark" style={{ border: "1px solid #ccc" }} interval={null} + onClick={() => { + setImageIndex(0); + handleShow(); + }} > {images.map((image, key) => ( @@ -245,18 +250,12 @@ const RobotDetails = () => { alignItems: "center", justifyContent: "center", overflow: "hidden", + width: "100%", // Adjust this to set the desired width + paddingTop: "0%", // This maintains the aspect ratio of the container as a square + position: "relative" as const, }} > - {image.caption} { - setImageIndex(key); - handleShow(); - }} - /> + { {images[imageIndex].caption} ({imageIndex + 1} of {images.length}{" "} - {userId}) + {userId} -
- {images[imageIndex].caption} +
+
diff --git a/frontend/src/pages/Robots.tsx b/frontend/src/pages/Robots.tsx index 31576c7a..cc2112a6 100644 --- a/frontend/src/pages/Robots.tsx +++ b/frontend/src/pages/Robots.tsx @@ -1,3 +1,4 @@ +import ImageComponent from "components/files/ViewImage"; import { api, Robot } from "hooks/api"; import { useAuthentication } from "hooks/auth"; import { useEffect, useState } from "react"; @@ -83,14 +84,20 @@ const Robots = () => { {robotsData.map((robot) => ( - + navigate(`/robot/${robot.robot_id}`)}> {robot.images[0] && ( - +
+ +
)} {robot.name} diff --git a/frontend/src/pages/TestImages.tsx b/frontend/src/pages/TestImages.tsx new file mode 100644 index 00000000..66d8492e --- /dev/null +++ b/frontend/src/pages/TestImages.tsx @@ -0,0 +1,15 @@ +import React from "react"; + +const App: React.FC = () => { + return ( +
+

Robot Images

+ {/* + + */} + {/* */} +
+ ); +}; + +export default App; diff --git a/frontend/src/pages/YourRobots.tsx b/frontend/src/pages/YourRobots.tsx index fb046d0c..9f5ccc61 100644 --- a/frontend/src/pages/YourRobots.tsx +++ b/frontend/src/pages/YourRobots.tsx @@ -1,3 +1,4 @@ +import ImageComponent from "components/files/ViewImage"; import { api, Robot } from "hooks/api"; import { useAuthentication } from "hooks/auth"; import { useEffect, useState } from "react"; @@ -66,14 +67,20 @@ const YourRobots = () => { {robotsData.map((robot) => ( - + navigate(`/robot/${robot.robot_id}`)}> {robot.images[0] && ( - +
+ +
)} {robot.name} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 5d9dc4f6..5547e6fe 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -27,4 +27,4 @@ "exclude": [ "node_modules" ] -} +} \ No newline at end of file diff --git a/store/app/crud/base.py b/store/app/crud/base.py index fa11f552..1c5784ca 100644 --- a/store/app/crud/base.py +++ b/store/app/crud/base.py @@ -8,6 +8,7 @@ from botocore.exceptions import ClientError from redis.asyncio import Redis from types_aiobotocore_dynamodb.service_resource import DynamoDBServiceResource +from types_aiobotocore_s3.service_resource import S3ServiceResource from store.settings import settings @@ -26,12 +27,22 @@ def db(self) -> DynamoDBServiceResource: raise RuntimeError("Must call __aenter__ first!") return self.__db + @property + def s3(self) -> S3ServiceResource: + if self.__s3 is None: + raise RuntimeError("Must call __aenter__ first!") + return self.__s3 + async def __aenter__(self) -> Self: session = aioboto3.Session() db = session.resource("dynamodb") db = await db.__aenter__() self.__db = db + s3 = session.resource("s3") + s3 = await s3.__aenter__() + self.__s3 = s3 + self.session_kv = Redis( host=settings.redis.host, password=settings.redis.password, @@ -65,6 +76,8 @@ async def __aenter__(self) -> Self: async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: # noqa: ANN401 if self.__db is not None: await self.__db.__aexit__(exc_type, exc_val, exc_tb) + # if self.__s3 is not None: + # await self.__s3.__aexit__(exc_type, exc_val, exc_tb) async def _create_dynamodb_table( self, diff --git a/store/app/crud/robots.py b/store/app/crud/robots.py index d89ad875..04dc59db 100644 --- a/store/app/crud/robots.py +++ b/store/app/crud/robots.py @@ -3,6 +3,9 @@ import logging from typing import List +from fastapi import UploadFile +from fastapi.responses import StreamingResponse + from store.app.crud.base import BaseCrud from store.app.model import Bom, Part, Robot @@ -56,10 +59,18 @@ async def delete_robot(self, robot_id: str) -> None: table = await self.db.Table("Robots") await table.delete_item(Key={"robot_id": robot_id}) - async def update_part(self, part_id: str, part: Part) -> None: - await self.delete_part(part_id) + async def update_part(self, part: Part) -> None: + await self.delete_part(part.part_id) await self.add_part(part) - async def update_robot(self, robot_id: str, robot: Robot) -> None: - await self.delete_robot(robot_id) + async def update_robot(self, robot: Robot) -> None: + await self.delete_robot(robot.robot_id) await self.add_robot(robot) + + async def get_image(self, url: str) -> StreamingResponse: + s3_object = await (await (await self.s3.Bucket("images")).Object(url)).get() + file_stream = s3_object["Body"] + return StreamingResponse(content=file_stream, media_type="image/png") + + async def upload_image(self, file: UploadFile) -> None: + await (await self.s3.Bucket("images")).upload_fileobj(file.file, file.filename or "") diff --git a/store/app/crud/users.py b/store/app/crud/users.py index 63e92499..45ee8de5 100644 --- a/store/app/crud/users.py +++ b/store/app/crud/users.py @@ -97,7 +97,7 @@ async def use_reset_password_token(self, token: str, new_password: str) -> None: id = await self.reset_password_kv.get(hash_token(token)) if id is None: raise ValueError("Provided token is invalid") - await self.change_password(id, new_password) + await self.change_password(id.decode("utf-8"), new_password) await self.delete_reset_password_token(token) async def add_change_email_token(self, token: str, user_id: str, new_email: str, lifetime: int) -> None: diff --git a/store/app/crypto.py b/store/app/crypto.py index 07488253..a1548063 100644 --- a/store/app/crypto.py +++ b/store/app/crypto.py @@ -8,7 +8,7 @@ from argon2 import PasswordHasher -def get_new_user_id() -> uuid.UUID: +def new_uuid() -> uuid.UUID: return uuid.uuid4() diff --git a/store/app/main.py b/store/app/main.py index 85667687..913889bd 100644 --- a/store/app/main.py +++ b/store/app/main.py @@ -4,6 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from store.app.routers.image import image_router from store.app.routers.part import parts_router from store.app.routers.robot import robots_router from store.app.routers.users import users_router @@ -37,3 +38,4 @@ async def read_root() -> bool: app.include_router(users_router, prefix="/users", tags=["users"]) app.include_router(robots_router, prefix="/robots", tags=["robots"]) app.include_router(parts_router, prefix="/parts", tags=["parts"]) +app.include_router(image_router, prefix="/image", tags=["image"]) diff --git a/store/app/routers/image.py b/store/app/routers/image.py new file mode 100644 index 00000000..21e9ddbb --- /dev/null +++ b/store/app/routers/image.py @@ -0,0 +1,48 @@ +"""Defines all robot related API endpoints.""" + +import io +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, UploadFile +from fastapi.responses import JSONResponse, StreamingResponse +from PIL import Image + +from store.app.crypto import new_uuid +from store.app.db import Crud + +image_router = APIRouter() + +logger = logging.getLogger(__name__) + + +@image_router.get("/{url}/") +async def get_image(url: str, crud: Annotated[Crud, Depends(Crud.get)]) -> StreamingResponse: + try: + return await crud.get_image(url + ".png") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@image_router.post("/upload/") +async def upload_image(crud: Annotated[Crud, Depends(Crud.get)], file: UploadFile) -> JSONResponse: + try: + if file.content_type != "image/png": + raise HTTPException(status_code=400, detail="Only PNG images are supported") + if len(await file.read()) > 1024 * 1024 * 2: + raise HTTPException(status_code=400, detail="Image is too large") + image_id = str(new_uuid()) + file.filename = image_id + ".png" + file.file.seek(0) + await crud.upload_image(file) + + image = Image.open(file.file) + compressed_image_io = io.BytesIO() + image.save(compressed_image_io, format="PNG", optimize=True, quality=30) + compressed_image_io.seek(0) + upload = UploadFile(filename="mini" + image_id + ".png", file=compressed_image_io) + await crud.upload_image(upload) + + return JSONResponse(status_code=200, content={"id": image_id}) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/store/app/routers/part.py b/store/app/routers/part.py index eff90191..efe3eb6d 100644 --- a/store/app/routers/part.py +++ b/store/app/routers/part.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException -from store.app.crypto import get_new_user_id +from store.app.crypto import new_uuid from store.app.db import Crud from store.app.model import Part from store.app.routers.users import get_session_token @@ -59,7 +59,7 @@ async def add_part( if user_id is None: raise HTTPException(status_code=401, detail="Must be logged in to add a part") part.owner = str(user_id) - part.part_id = str(get_new_user_id()) + part.part_id = str(new_uuid()) await crud.add_part(part) return True @@ -92,5 +92,5 @@ async def edit_part( raise HTTPException(status_code=401, detail="Must be logged in to edit a part") part.owner = str(user_id) part.part_id = part_id - await crud.update_part(part_id, part) + await crud.update_part(part) return True diff --git a/store/app/routers/robot.py b/store/app/routers/robot.py index 65c62a85..21cb8867 100644 --- a/store/app/routers/robot.py +++ b/store/app/routers/robot.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException -from store.app.crypto import get_new_user_id +from store.app.crypto import new_uuid from store.app.db import Crud from store.app.model import Robot from store.app.routers.users import get_session_token @@ -58,7 +58,7 @@ async def add_robot( if user_id is None: raise HTTPException(status_code=401, detail="Must be logged in to add a robot") robot.owner = str(user_id) - robot.robot_id = str(get_new_user_id()) + robot.robot_id = str(new_uuid()) await crud.add_robot(robot) return True @@ -91,5 +91,5 @@ async def edit_robot( raise HTTPException(status_code=401, detail="Must be logged in to edit a robot") robot.owner = str(user_id) robot.robot_id = robot_id - await crud.update_robot(robot_id, robot) + await crud.update_robot(robot) return True diff --git a/store/requirements.txt b/store/requirements.txt index 25c3edaa..5f41e97b 100644 --- a/store/requirements.txt +++ b/store/requirements.txt @@ -16,11 +16,11 @@ argon2-cffi aiohttp aiosmtplib fastapi -pyjwt +pillow python-multipart # Deployment dependencies. uvicorn[standard] # Types -types-aioboto3[dynamodb] +types-aioboto3[dynamodb, s3]