diff --git a/Pipfile.lock b/Pipfile.lock index 62f7834..27dae22 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -291,16 +291,16 @@ "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" ], - "markers": "python_version >= '3.5'", + "markers": "python_full_version >= '3.5.0'", "version": "==2.0.12" }, "click": { "hashes": [ - "sha256:19a4baa64da924c5e0cd889aba8e947f280309f1a2ce0947a3e3a7bcb7cc72d6", - "sha256:977c213473c7665d3aa092b41ff12063227751c41d7b17165013e10069cc5cd2" + "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e", + "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72" ], "markers": "python_version >= '3.7'", - "version": "==8.1.0" + "version": "==8.1.2" }, "databases": { "hashes": [ @@ -536,7 +536,7 @@ "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" ], - "markers": "python_version >= '3.5'", + "markers": "python_full_version >= '3.5.0'", "version": "==3.3" }, "jinja2": { @@ -914,11 +914,11 @@ }, "redis": { "hashes": [ - "sha256:3cbe235cea80b9c9991b397567aa2d65eb4e6fb09787f61d227ae82eb4eb50b4", - "sha256:6758d01dec81af191b98a35cce3402675d115456584c39b500ab485a5e386bbb" + "sha256:69d05fac17bf3f43937afbb775c536eb516bd21355a4f17d59a966f4a531ce71", + "sha256:fe45513881229dbee610620b9e0817b1f48c47ba635870320fd44a712204bbdd" ], "index": "pypi", - "version": "==4.2.0" + "version": "==4.2.1" }, "rsa": { "hashes": [ @@ -938,11 +938,11 @@ }, "setuptools": { "hashes": [ - "sha256:8f4813dd6a4d6cc17bde85fb2e635fe19763f96efbb0ddf5575562e5ee0bc47a", - "sha256:c3d4e2ab578fbf83775755cd76dae73627915a22832cf4ea5de895978767833b" + "sha256:425ec0e0014c5bcc1104dd1099de6c8f0584854fc9a4f512575f5ed5ee399fb9", + "sha256:6d59c30ce22dd583b42cacf51eebe4c6ea72febaa648aa8b30e5015d23a191fe" ], "markers": "python_version >= '3.7'", - "version": "==61.2.0" + "version": "==61.3.0" }, "six": { "hashes": [ @@ -957,7 +957,7 @@ "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663", "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de" ], - "markers": "python_version >= '3.5'", + "markers": "python_full_version >= '3.5.0'", "version": "==1.2.0" }, "sqlalchemy": { @@ -1060,10 +1060,10 @@ }, "watchgod": { "hashes": [ - "sha256:4ba20c2fa3e63df706ab50e694b9453b05395fadb7cbbfd984d71fb1547d485d", - "sha256:c12d15f3df7d11e740704e45398277f75f1d78f46ad59ca9d7505bfd8b8d3086" + "sha256:2f3e8137d98f493ff58af54ea00f4d1433a6afe2ed08ab331a657df468c6bfce", + "sha256:cb11ff66657befba94d828e3b622d5fb76f22fbda1376f355f3e6e51e97d9450" ], - "version": "==0.8.1" + "version": "==0.8.2" }, "websockets": { "hashes": [ diff --git a/classquiz/__init__.py b/classquiz/__init__.py index 766267d..fc74a2e 100644 --- a/classquiz/__init__.py +++ b/classquiz/__init__.py @@ -4,7 +4,7 @@ from socketio import ASGIApp from classquiz.db import database from classquiz.config import settings -from classquiz.routers import users, quiz, utils, stats +from classquiz.routers import users, quiz, utils, stats, storage from classquiz.socket_server import sio from sentry_sdk.integrations.redis import RedisIntegration @@ -50,4 +50,5 @@ app.include_router(users.router, tags=["users"], prefix="/api/v1/users") app.include_router(quiz.router, tags=["quiz"], prefix="/api/v1/quiz") app.include_router(utils.router, tags=["utils"], prefix="/api/v1/utils") app.include_router(stats.router, tags=["stats"], prefix="/api/v1/stats") +app.include_router(storage.router, tags=["storage"], prefix="/api/v1/storage") app.mount("/", ASGIApp(sio)) diff --git a/classquiz/config.py b/classquiz/config.py index dde6fea..45aaae6 100644 --- a/classquiz/config.py +++ b/classquiz/config.py @@ -18,11 +18,17 @@ class Settings(BaseSettings): mail_server: str mail_port: int secret_key: str - imgur_client_id: str = "b13fefc8bb1db87" + imgur_client_id: str = "27aa1687703ddb9" access_token_expire_minutes: int = 30 cache_expiry: int = 86400 sentry_dsn: str | None = "https://4981de1f72f24fd7b5e21b8913b93a02@o661934.ingest.sentry.io/6254641" + # storage_backend + storage_backend: str | None = "deta" + # if storage_backend == "deta": + deta_project_key: str + deta_project_id: str + class Config: env_file = ".env" env_file_encoding = 'utf-8' diff --git a/classquiz/kahoot_importer/import_quiz.py b/classquiz/kahoot_importer/import_quiz.py index 826d402..be0c5fc 100644 --- a/classquiz/kahoot_importer/import_quiz.py +++ b/classquiz/kahoot_importer/import_quiz.py @@ -7,44 +7,51 @@ import html from classquiz.db.models import Quiz, QuizAnswer, QuizQuestion, User from classquiz.kahoot_importer.get import get as get_quiz from classquiz.config import settings +from classquiz.storage import Storage + settings = settings() + async def _download_image(url: str) -> bytes: async with ClientSession() as session: async with session.get(url) as resp: return await resp.read() -async def _upload_to_imgur(image: bytes, album_id: str | None) -> str: - """ - Uploads an image to Imgur. - :param image: The image to upload. - :return: The URL of the image on Imgur. - """ - - formdata = FormData() - formdata.add_field('image', image, filename='image.png') - if album_id: - formdata.add_field('album', album_id) - async with ClientSession(headers={"Authorization": f"Client-ID {settings.imgur_client_id}"}) as session: - async with session.post("https://api.imgur.com/3/image", data=image) as resp: - data = await resp.json() - return data["data"]["link"] - - -async def _generate_imgur_album(quiz_id: str) -> str: - """ - Generates an album on Imgur. - :param quiz_id: The ID of the quiz. - :return: The URL of the album on Imgur. - """ - formdata = FormData() - formdata.add_field('title', quiz_id) - async with ClientSession(headers={"Authorization": f"Client-ID {settings.imgur_client_id}"}) as session: - async with session.post("https://api.imgur.com/3/album", data={"title": "Quiz: " + quiz_id}, ) as resp: - data = await resp.json() - return data["data"]["id"] - +# async def _upload_to_imgur(image: bytes, album_id: str | None) -> str: +# """ +# Uploads an image to Imgur. +# :param image: The image to upload. +# :return: The URL of the image on Imgur. +# """ +# +# formdata = FormData() +# formdata.add_field('image', image, filename='image.png') +# if album_id: +# formdata.add_field('album', album_id) +# async with ClientSession(headers={"Authorization": f"Client-ID {settings.imgur_client_id}"}) as session: +# print(settings.imgur_client_id) +# async with session.post("https://api.imgur.com/3/image", data=image) as resp: +# data = await resp.json() +# print(resp.headers) +# try: +# return data["data"]["link"] +# except KeyError: +# raise Exception(data) +# +# +# async def _generate_imgur_album(quiz_id: str) -> str: +# # """ +# # Generates an album on Imgur. +# # :param quiz_id: The ID of the quiz. +# # :return: The URL of the album on Imgur. +# # """ +# formdata = FormData() +# formdata.add_field('title', quiz_id) +# async with ClientSession(headers={"Authorization": f"Client-ID {settings.imgur_client_id}"}) as session: +# async with session.post("https://api.imgur.com/3/album", data={"title": "Quiz: " + quiz_id}, ) as resp: +# data = await resp.json() +# return data["data"]["id"] async def import_quiz(quiz_id: str, user: User) -> Quiz | str: """ @@ -57,20 +64,24 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str: if quiz is None: return "quiz not found" quiz_questions: list[dict] = [] - imgur_album_id = await _generate_imgur_album(quiz_id) + quiz_id = uuid.uuid4() + storage = Storage(backend=settings.storage_backend, deta_key=settings.deta_project_key, + deta_id=settings.deta_project_id) for q in quiz.kahoot.questions: answers: list[QuizAnswer] = [] image = None if q.image is not None and q.image != "": image_bytes = await _download_image(q.image) - image = await _upload_to_imgur(image_bytes, imgur_album_id) + image_name= f"{quiz_id}--{uuid.uuid4()}" + image = await storage.upload(file_name=image_name, file_data=image_bytes) + image = f"{settings.root_address}/api/v1/storage/download/{image_name}" # image = q.image for a in q.choices: answers.append((QuizAnswer(right=a.correct, answer=html.unescape(a.answer)))) quiz_questions.append( QuizQuestion(question=q.question, answers=answers, time=str(q.time / 1000), image=image).dict()) - quiz_data = Quiz(id=uuid.uuid4(), public=False, title=quiz.kahoot.title, description=quiz.kahoot.description, + quiz_data = Quiz(id=quiz_id, public=False, title=quiz.kahoot.title, description=quiz.kahoot.description, created_at=datetime.now(), updated_at=datetime.now(), user_id=user.id, questions=json.dumps(quiz_questions)) return await quiz_data.save() diff --git a/classquiz/routers/quiz.py b/classquiz/routers/quiz.py index 8334052..9399cc3 100644 --- a/classquiz/routers/quiz.py +++ b/classquiz/routers/quiz.py @@ -9,8 +9,9 @@ from fastapi.responses import JSONResponse import re from classquiz.auth import get_current_user, get_current_user_optional -from classquiz.config import redis +from classquiz.config import redis, settings from classquiz.db.models import Quiz, QuizInput, User, PlayGame +settings = settings() router = APIRouter() @@ -18,9 +19,10 @@ router = APIRouter() @router.post("/create") async def create_quiz_lol(quiz_input: QuizInput, user: User = Depends(get_current_user)): imgur_regex = r"^https://i\.imgur\.com\/.{7}.(jpg|png|gif)$" + server_regex = rf"^{settings.root_address}/api/v1/storage/download/.{36}--.{36}$" for question in quiz_input.questions: if question.image is not None: - if not re.match(imgur_regex, question.image): + if not re.match(imgur_regex, question.image) or not re.match(server_regex, question.image): raise HTTPException(status_code=400, detail="image url is not valid") quiz = Quiz(**quiz_input.dict(), user_id=user.id, id=uuid.uuid4()) await redis.delete("global_quiz_count") diff --git a/classquiz/routers/storage.py b/classquiz/routers/storage.py new file mode 100644 index 0000000..d8ee607 --- /dev/null +++ b/classquiz/routers/storage.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter +from fastapi.responses import StreamingResponse +from classquiz.config import settings +import io +from classquiz.storage import Storage + +settings = settings() + +router = APIRouter() + + +@router.get('/download/{file_name}') +async def download_file(file_name: str): + storage = Storage(backend=settings.storage_backend, deta_key=settings.deta_project_key, + deta_id=settings.deta_project_id) + download = await storage.download(file_name) + + def iter_file(): + yield from download + + return StreamingResponse(iter_file(), media_type='image/*') diff --git a/classquiz/socket_server/__init__.py b/classquiz/socket_server/__init__.py index a4730c2..aec6f78 100644 --- a/classquiz/socket_server/__init__.py +++ b/classquiz/socket_server/__init__.py @@ -13,12 +13,16 @@ settings = settings() @sio.event async def join_game(sid, data): async with aiohttp.ClientSession() as session: - async with session.post("https://hcaptcha.com/siteverify", - data={"response": data["captcha"], "secret": settings.hcaptcha_key}) as resp: - resp_data = await resp.json() - if not resp_data["success"]: - print("CAPTCHA FAILED") - return + try: + async with session.post("https://hcaptcha.com/siteverify", + data={"response": data["captcha"], "secret": settings.hcaptcha_key}) as resp: + resp_data = await resp.json() + if not resp_data["success"]: + print("CAPTCHA FAILED") + return + except KeyError as e: + print("CAPTCHA FAILED") + return redis_res = await redis.get(f"game:{data['game_pin']}") if redis_res is None: await sio.emit("game_not_found", room=sid) diff --git a/classquiz/storage/__init__.py b/classquiz/storage/__init__.py new file mode 100644 index 0000000..5e11cbd --- /dev/null +++ b/classquiz/storage/__init__.py @@ -0,0 +1,25 @@ +from .deta_storage import DetaStorage +from io import BytesIO + + +class Storage: + def __init__(self, backend: str, deta_key: str | None, deta_id: str | None): + self.backend = backend + self.deta_key = deta_key + self.deta_id = deta_id + self.deta_base_url = f"https://drive.deta.sh/v1/{deta_id}/classquiz" + self.deta_instance = DetaStorage(deta_base_url=self.deta_base_url, deta_key=self.deta_key, deta_id=self.deta_id) + if backend == "deta": + if deta_key is None or deta_id is None: + raise ValueError("deta_key and deta_id must be provided") + else: + raise NotImplementedError(f"Backend {backend} not implemented") + + async def download(self, file_name: str) -> BytesIO | None: + if self.backend == "deta": + return await self.deta_instance.download( + file_name) + + async def upload(self, file_name: str, file_data: bytes) -> None: + if self.backend == "deta": + return await self.deta_instance.upload(file=file_data, file_name=file_name) diff --git a/classquiz/storage/deta_storage.py b/classquiz/storage/deta_storage.py new file mode 100644 index 0000000..7fa929b --- /dev/null +++ b/classquiz/storage/deta_storage.py @@ -0,0 +1,43 @@ +from classquiz.config import settings +from aiohttp import ClientSession +from io import BytesIO + +settings = settings() + + +class DetaStorage: + def __init__(self, deta_base_url: str, deta_id: str, deta_key: str): + self.deta_url = deta_base_url + self.deta_id = deta_id + self.deta_key = deta_key + self.headers = { + "X-Api-Key": self.deta_key, + } + + async def download(self, file_name: str) -> BytesIO | None: + """ + + :param file_name: The name of the file to be downloaded + :return: Either bytes f successfull download or None if failed + """ + async with ClientSession(headers=self.headers) as session: + async with session.get(f"{self.deta_url}/files/download?name={file_name}") as response: + if response.status == 200: + return BytesIO(await response.read()) + elif response.status == 404: + return None + else: + raise Exception("Download failed") + + async def upload(self, file: bytes, file_name: str) -> None: + """ + :param file: The file in bytes + :param file_name: The name of the file + :return: + """ + async with ClientSession(headers=self.headers) as session: + async with session.post(f"{self.deta_url}/files?name={file_name}", data=file) as response: + if response.status == 201: + return None + else: + raise Exception("Upload failed") diff --git a/frontend/src/lib/editor.svelte b/frontend/src/lib/editor.svelte index 58c1d10..af382fb 100644 --- a/frontend/src/lib/editor.svelte +++ b/frontend/src/lib/editor.svelte @@ -40,11 +40,14 @@ const checkIfAllQuestionImagesComplyWithRegex = (questions: Array) => { let NoteverythingValid = false; - const regex = /^https:\/\/i\.imgur\.com\/.{7}.(jpg|png|gif)$/; + // const regex = /^https:\/\/i\.imgur\.com\/.{7}.(jpg|png|gif)$/; + // const local_regex = /^http(|s):\/\/\w*(|:)\d*\/api\/v1\/storage\/download\/.{36}--.{36}$/g; + const main_regex = + /^(http(|s):\/\/\w*(|:)\d*\/api\/v1\/storage\/download\/.{36}--.{36}|https:\/\/i\.imgur\.com\/.{7}.(jpg|png|gif))$/; for (let i = 0; i < questions.length; i++) { const question = questions[i]; - if (question.image && !regex.test(question.image)) { - console.log('not valid'); + + if (question.image && !main_regex.test(question.image)) { NoteverythingValid = true; } } diff --git a/frontend/src/lib/play/question.svelte b/frontend/src/lib/play/question.svelte index b2f7916..1040bcc 100644 --- a/frontend/src/lib/play/question.svelte +++ b/frontend/src/lib/play/question.svelte @@ -50,7 +50,11 @@ {#if question.image !== null}
- Question image + Content for Question
{/if} {#if timer_res !== '0'}