diff --git a/classquiz/db/models.py b/classquiz/db/models.py index 763c0d3..dd6f63d 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -398,3 +398,35 @@ class StorageItem(ormar.Model): tablename = "storage_items" metadata = metadata database = database + + +class PublicStorageItem(BaseModel): + id: uuid.UUID + uploaded_at: datetime + mime_type: str + hash: str | None + size: int + deleted_at: datetime | None + alt_text: str | None + filename: str | None + + @classmethod + def from_db_model(cls, data: StorageItem): + hash_data = None + if data.hash is not None: + hash_data = data.hash.hex() + return cls( + id=data.id, + uploaded_at=data.uploaded_at, + mime_type=data.mime_type, + hash=hash_data, + size=data.size, + deleted_at=data.deleted_at, + alt_text=data.alt_text, + filename=data.filename, + ) + + +class UpdateStorageItem(BaseModel): + filename: str + alt_text: str diff --git a/classquiz/helpers/__init__.py b/classquiz/helpers/__init__.py index 90dcb66..89698b9 100644 --- a/classquiz/helpers/__init__.py +++ b/classquiz/helpers/__init__.py @@ -3,6 +3,7 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. import asyncio +import uuid from typing import Optional import ormar.exceptions @@ -175,3 +176,23 @@ def check_hashcash(data: str, input_data: str, claim_in: Optional[str] = "19") - return False some_error = [version == "1", claim == claim_in, res == input_data, ext == ""] return all(el is True for el in some_error) + + +def check_image_string(image: str) -> (bool, uuid.UUID | None): + # Valid formats: {uuid} and {uuid}--{uuid} + try: + parsed_uuid = uuid.UUID(image) + return True, parsed_uuid + except ValueError: + pass + + split_image = image.split("--") + if len(split_image) != 2: + return False, None + + try: + uuid.UUID(split_image[0]) + uuid.UUID(split_image[1]) + return True, None + except ValueError: + return False, None diff --git a/classquiz/kahoot_importer/import_quiz.py b/classquiz/kahoot_importer/import_quiz.py index f64dabb..87d7fb2 100644 --- a/classquiz/kahoot_importer/import_quiz.py +++ b/classquiz/kahoot_importer/import_quiz.py @@ -48,8 +48,8 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str: if q.image is not None and q.image != "": image_bytes = await _download_image(q.image) 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}" + await storage.upload(file_name=image_name, file_data=image_bytes) + image = image_name for i, a in enumerate(q.choices): answers.append( ( @@ -74,7 +74,7 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str: image_bytes = await _download_image(quiz.kahoot.cover) image_name = f"{quiz_id}--{uuid.uuid4()}" await storage.upload(file_name=image_name, file_data=image_bytes) - cover = f"{settings.root_address}/api/v1/storage/download/{image_name}" + cover = image_name quiz_data = Quiz( id=quiz_id, public=True, diff --git a/classquiz/routers/editor.py b/classquiz/routers/editor.py index 98a60ba..df6f6a4 100644 --- a/classquiz/routers/editor.py +++ b/classquiz/routers/editor.py @@ -4,24 +4,22 @@ import asyncio import html -import re import uuid from typing import Optional import asyncpg.exceptions import bleach -from fastapi import APIRouter, File, UploadFile, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel -from classquiz.config import settings, redis, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ, server_regex +from classquiz.config import settings, redis, storage, meilisearch, ALLOWED_TAGS_FOR_QUIZ from classquiz.db.models import Quiz, QuizInput, User, QuizQuestionType -import puremagic from classquiz.auth import get_current_user import os from datetime import datetime from uuid import UUID -from classquiz.helpers import get_meili_data, check_hashcash +from classquiz.helpers import get_meili_data, check_image_string from classquiz.storage.errors import DeletionFailedError settings = settings() @@ -86,37 +84,6 @@ class UploadImageReturn(BaseModel): pow_data: str -@router.post("/image", response_model=UploadImageReturn) -async def upload_image(edit_id: str, pow_data: str, file: UploadFile = File()): - session_data = await redis.get(f"edit_session:{edit_id}") - pow_data_server = await redis.get(f"edit_session:{edit_id}:pow") - uploaded_images = await redis.llen(f"edit_session:{edit_id}:images") - if pow_data_server is None: - raise HTTPException(status_code=401, detail="Edit ID not found!") - if session_data is None: - raise HTTPException(status_code=401, detail="Edit ID not found!") - if uploaded_images == 0 and not check_hashcash(pow_data, pow_data_server, "8"): - raise HTTPException(status_code=401, detail="Edit ID not found!") - if uploaded_images != 0 and not check_hashcash(pow_data, pow_data_server, "8"): - raise HTTPException(status_code=401, detail="Edit ID not found!") - file_bytes = await file.read() - if len(file_bytes) > 2000000: - raise HTTPException(status_code=400, detail="File too large") - try: - pm_data = puremagic.magic_string(file_bytes)[0] - except puremagic.PureError: - raise HTTPException(status_code=400, detail="Image couldn't be identified!") - if pm_data.extension not in allowed_image_extensions: - raise HTTPException(status_code=400, detail="Image-type now allowed!") - session_data = EditSessionData.parse_raw(session_data) - file_name = f"{session_data.quiz_id}--{uuid.uuid4()}" - await storage.upload(file_name=file_name, file_data=file_bytes) - await redis.lpush(f"edit_session:{edit_id}:images", file_name) - random_str = os.urandom(8).hex() - await redis.set(f"edit_session:{edit_id}:pow", random_str, ex=3800) - return UploadImageReturn(id=file_name, pow_data=random_str) - - @router.post("/finish") async def finish_edit(edit_id: str, quiz_input: QuizInput): session_data = await redis.get(f"edit_session:{edit_id}") @@ -141,10 +108,7 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput): quiz_input.questions[i].answers[i2].answer = html.unescape( bleach.clean(answer.answer, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True) ) - image_id_regex = r"^.{36}--.{36}$" - imgur_regex = r"^https://i\.imgur\.com\/.{7}.(jpg|png|gif)$" - extract_file_name_re = r"^.*/api/v1/storage/download/(.{36}--.{36})$" images_to_delete = [] old_quiz_data: Quiz = await Quiz.objects.get_or_none(id=session_data.quiz_id, user_id=session_data.user_id) @@ -167,15 +131,9 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput): ) if image == "": question.image = None + if image is None: mark_image_for_deletion(question.image, i, old_quiz_data) - elif image is None: - mark_image_for_deletion(question.image, i, old_quiz_data) - elif bool(re.match(image_id_regex, question.image)): - question.image = f"{settings.root_address}/api/v1/storage/download/{image}" - mark_image_for_deletion(question.image, i, old_quiz_data) - elif bool(re.match(imgur_regex, image)): - mark_image_for_deletion(question.image, i, old_quiz_data) - elif bool(re.match(server_regex, image)): + elif check_image_string(quiz_input.cover_image)[0]: mark_image_for_deletion(question.image, i, old_quiz_data) else: raise HTTPException(status_code=400, detail="Image URL(s) aren't valid!") @@ -186,7 +144,10 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput): # if quiz_input.background_image is None and old_quiz_data.background_image is not None: # mark_image_for_deletion(quiz_input.background_image) - if quiz_input.cover_image is not None and not bool(re.match(server_regex, quiz_input.cover_image)): + if quiz_input.cover_image is not None and not check_image_string(quiz_input.cover_image)[0]: + raise HTTPException(status_code=400, detail="image url is not valid") + + if quiz_input.background_image is not None and not check_image_string(quiz_input.background_image)[0]: raise HTTPException(status_code=400, detail="image url is not valid") if session_data.edit: @@ -207,7 +168,7 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput): for image in images_to_delete: if image is not None: try: - await storage.delete([re.search(extract_file_name_re, image).group(1)]) + await storage.delete([image]) except DeletionFailedError: pass await redis.srem("edit_sessions", edit_id) diff --git a/classquiz/routers/storage.py b/classquiz/routers/storage.py index 1998def..1831f34 100644 --- a/classquiz/routers/storage.py +++ b/classquiz/routers/storage.py @@ -9,9 +9,9 @@ from fastapi.responses import StreamingResponse, RedirectResponse from classquiz.auth import get_current_user from classquiz.config import settings, storage, arq -from classquiz.db.models import User, StorageItem +from classquiz.db.models import User, StorageItem, PublicStorageItem, UpdateStorageItem from classquiz.storage.errors import DownloadingFailedError -from uuid import uuid4 +from uuid import uuid4, UUID settings = settings() @@ -45,7 +45,7 @@ async def download_file(file_name: str): @router.post("/") -async def upload_file(file: UploadFile = File(), user: User = Depends(get_current_user)): +async def upload_file(file: UploadFile = File(), user: User = Depends(get_current_user)) -> PublicStorageItem: file_id = uuid4() size = 0 @@ -71,3 +71,39 @@ async def upload_file(file: UploadFile = File(), user: User = Depends(get_curren await storage.upload(file_name=file_id.hex, file_data=file_data) await file_obj.save() await arq.enqueue_job("calculate_hash", file_id.hex) + return PublicStorageItem.from_db_model(file_obj) + + +@router.get("/meta/{file_id}") +async def get_file_info(file_id: UUID, user: User = Depends(get_current_user)) -> PublicStorageItem: + file_data = await StorageItem.objects.get_or_none(id=file_id, user=user, deleted_at=None) + if file_data is None: + raise HTTPException(status_code=404, detail="File not found") + return PublicStorageItem.from_db_model(file_data) + + +@router.delete("/meta/{file_id}") +async def mark_file_as_deleted(file_id: UUID, user: User = Depends(get_current_user)): + file_data = await StorageItem.objects.get_or_none(id=file_id, user=user, deleted_at=None) + if file_data is None: + raise HTTPException(status_code=404, detail="File not found") + storage_path = file_data.storage_path + if storage_path is None: + storage_path = file_data.id.hex + await storage.delete(storage_path) + file_data.deleted_at = datetime.now() + await file_data.update() + return + + +@router.put("/meta/{file_id}") +async def update_image_data( + file_id: UUID, data: UpdateStorageItem, user: User = Depends(get_current_user) +) -> PublicStorageItem: + file_data = await StorageItem.objects.get_or_none(id=file_id, user=user, deleted_at=None) + if file_data is None: + raise HTTPException(status_code=404, detail="File not found") + file_data.filename = data.filename + file_data.alt_text = data.alt_text + await file_data.update() + return PublicStorageItem.from_db_model(file_data) diff --git a/classquiz/worker/storage.py b/classquiz/worker/storage.py index 0ab8338..376e283 100644 --- a/classquiz/worker/storage.py +++ b/classquiz/worker/storage.py @@ -3,6 +3,7 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. import uuid +from arq.worker import Retry import xxhash from classquiz.config import redis, storage @@ -36,7 +37,11 @@ async def calculate_hash(ctx, file_id_as_str: str): if file_data.storage_path is not None: file_path = file_data.storage_path file = SpooledTemporaryFile() - file.write((await storage.download(file_path)).getbuffer().tobytes()) + file_bytes = await storage.download(file_path) + if file_bytes is None: + print("Retry raised!") + raise Retry(defer=ctx["job_try"] * 10) + file.write(file_bytes.getbuffer().tobytes()) hash_obj = xxhash.xxh3_128() # assert hash_obj.block_size == 64 while chunk := file.read(6400): diff --git a/frontend/src/lib/admin.svelte b/frontend/src/lib/admin.svelte index b856d4f..3451686 100644 --- a/frontend/src/lib/admin.svelte +++ b/frontend/src/lib/admin.svelte @@ -225,7 +225,8 @@ {#if quiz_data.questions[selected_question].image !== null}
None: session = Session(bind=conn) all_cover_images = session.execute("SELECT cover_image, id from quiz where cover_image is not null;") for cover_image, id in all_cover_images: - new_cover_image = re.search(magic_regex, cover_image).group(1) - # print(new_cover_image, id) - session.execute(f"UPDATE quiz SET cover_image = '{new_cover_image}' WHERE id='{id}';") + try: + new_cover_image = re.search(magic_regex, cover_image).group(1) + session.execute(f"UPDATE quiz SET cover_image = '{new_cover_image}' WHERE id='{id}';") + except AttributeError: + continue all_background_images = session.execute("SELECT background_image, id from quiz where background_image is not null;") for bg_image, id in all_background_images: - new_bg_image = re.search(magic_regex, bg_image).group(1) - # print(new_cover_image, id) - session.execute(f"UPDATE quiz SET cover_image = '{new_bg_image}' WHERE id='{id}';") + try: + new_bg_image = re.search(magic_regex, bg_image).group(1) + session.execute(f"UPDATE quiz SET cover_image = '{new_bg_image}' WHERE id='{id}';") + except AttributeError: + continue all_questions = session.execute("SELECT questions, id from quiz;") question_image_regex = rf"{settings.root_address}/api/v1/storage/download/(?=.{{36}}--.{{36}})" @@ -71,5 +75,5 @@ def downgrade() -> None: question_image_regex = r"(?=.{36}--.{36})" for question, id in all_questions: question_as_json = json.dumps(question) - result = re.sub(question_image_regex, f"{settings.root_address}/api/v1/storage/download", question_as_json) + result = re.sub(question_image_regex, f"{settings.root_address}/api/v1/storage/download/", question_as_json) session.execute(f"UPDATE quiz SET questions = '{result}' WHERE id='{id}';")