From 10668e420bee8769d9b6af8f051b7e6dd9612695 Mon Sep 17 00:00:00 2001 From: Mawoka Date: Sat, 27 May 2023 16:37:21 +0200 Subject: [PATCH] :sparkles: Migrated from image urls to image uuids --- classquiz/db/models.py | 32 ++++++++++ classquiz/helpers/__init__.py | 21 +++++++ classquiz/kahoot_importer/import_quiz.py | 6 +- classquiz/routers/editor.py | 59 ++++--------------- classquiz/routers/storage.py | 42 ++++++++++++- classquiz/worker/storage.py | 7 ++- frontend/src/lib/admin.svelte | 5 +- frontend/src/lib/dashboard/main_slider.svelte | 4 +- frontend/src/lib/editor/card.svelte | 3 +- frontend/src/lib/editor/settings-card.svelte | 4 +- frontend/src/lib/editor/sidebar.svelte | 4 +- frontend/src/lib/editor/uploader.svelte | 18 +++--- frontend/src/lib/i18n/locales/en.json | 4 ++ frontend/src/lib/play/question.svelte | 2 +- frontend/src/lib/play/title.svelte | 6 +- frontend/src/lib/practice/question.svelte | 2 +- frontend/src/lib/practice/title_screen.svelte | 2 +- frontend/src/lib/yupSchemas.ts | 9 +-- frontend/src/routes/dashboard/+page.svelte | 2 +- frontend/src/routes/remote/+page.svelte | 3 +- .../src/routes/user/[user_id]/+page.svelte | 2 +- .../src/routes/view/[quiz_id]/+page.svelte | 8 ++- ...dont_include_host_in_image_urls_anymore.py | 18 +++--- 23 files changed, 163 insertions(+), 100 deletions(-) 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}
Content for Question @@ -320,7 +321,7 @@
Not provided
diff --git a/frontend/src/lib/dashboard/main_slider.svelte b/frontend/src/lib/dashboard/main_slider.svelte index 5e418d8..963d4b7 100644 --- a/frontend/src/lib/dashboard/main_slider.svelte +++ b/frontend/src/lib/dashboard/main_slider.svelte @@ -158,7 +158,7 @@
Not provided @@ -302,7 +302,7 @@ {#if visibleImages?.[i]?.[q]} Not provided {/if} diff --git a/frontend/src/lib/editor/card.svelte b/frontend/src/lib/editor/card.svelte index 5540a81..5c80b61 100644 --- a/frontend/src/lib/editor/card.svelte +++ b/frontend/src/lib/editor/card.svelte @@ -129,7 +129,8 @@ not available diff --git a/frontend/src/lib/editor/settings-card.svelte b/frontend/src/lib/editor/settings-card.svelte index 988049e..e105767 100644 --- a/frontend/src/lib/editor/settings-card.svelte +++ b/frontend/src/lib/editor/settings-card.svelte @@ -42,7 +42,7 @@
@@ -68,7 +68,7 @@ {#if data.cover_image != undefined && data.cover_image !== ''}
not available { diff --git a/frontend/src/lib/editor/sidebar.svelte b/frontend/src/lib/editor/sidebar.svelte index 725297e..3539974 100644 --- a/frontend/src/lib/editor/sidebar.svelte +++ b/frontend/src/lib/editor/sidebar.svelte @@ -201,11 +201,11 @@ {#if question.image}
Not available`, + content: `Not available`, allowHTML: true }} /> diff --git a/frontend/src/lib/editor/uploader.svelte b/frontend/src/lib/editor/uploader.svelte index 3940fdc..46a7add 100644 --- a/frontend/src/lib/editor/uploader.svelte +++ b/frontend/src/lib/editor/uploader.svelte @@ -45,33 +45,29 @@ quality: 0.6 }) .use(XHRUpload, { - endpoint: `/api/v1/editor/image?edit_id=${edit_id}&pow_data=${pow_data}` + endpoint: `/api/v1/storage/` }); const props = { inline: true, restrictions: { - maxFileSize: 2_000_000, - maxNumberOfFiles: 1, - allowedFileTypes: ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp'] + maxFileSize: 10_000_000, + maxNumberOfFiles: 1 + // allowedFileTypes: ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp'] } }; let image_id; uppy.on('upload-success', (file, response) => { image_id = response.body.id; - pow_salt = response.body.pow_data; - console.log(pow_salt, response.body); pow_data = undefined; }); uppy.on('complete', (_) => { console.log(pow_data); if (selected_question === undefined) { - data.cover_image = `${window.location.origin}/api/v1/storage/download/${image_id}`; + data.cover_image = image_id; } else if (selected_question === -1) { - data.background_image = `${window.location.origin}/api/v1/storage/download/${image_id}`; + data.background_image = image_id; } else { - data.questions[ - selected_question - ].image = `${window.location.origin}/api/v1/storage/download/${image_id}`; + data.questions[selected_question].image = image_id; } console.log(selected_question, data); diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index e36eabf..1df76c6 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -384,5 +384,9 @@ "popover": { "copied_to_clipboard": "Copied to clipboard!" } + }, + "public_user_page": { + "joined_on": "Joined on {{date}}", + "no_original_quizzes": "This user doesn't have any original quizzes" } } diff --git a/frontend/src/lib/play/question.svelte b/frontend/src/lib/play/question.svelte index 7f504cd..bad7513 100644 --- a/frontend/src/lib/play/question.svelte +++ b/frontend/src/lib/play/question.svelte @@ -154,7 +154,7 @@ {#if question.image !== null && game_mode !== 'kahoot'}
Content for Question diff --git a/frontend/src/lib/play/title.svelte b/frontend/src/lib/play/title.svelte index e18a6c5..c3ac427 100644 --- a/frontend/src/lib/play/title.svelte +++ b/frontend/src/lib/play/title.svelte @@ -15,7 +15,11 @@ {#if cover_image}
- Not provided + Not provided
{/if} diff --git a/frontend/src/lib/practice/question.svelte b/frontend/src/lib/practice/question.svelte index 0339d4a..b582b42 100644 --- a/frontend/src/lib/practice/question.svelte +++ b/frontend/src/lib/practice/question.svelte @@ -94,7 +94,7 @@ {#if question.image !== null}
Content for Question diff --git a/frontend/src/lib/practice/title_screen.svelte b/frontend/src/lib/practice/title_screen.svelte index de7b567..1dd9d20 100644 --- a/frontend/src/lib/practice/title_screen.svelte +++ b/frontend/src/lib/practice/title_screen.svelte @@ -46,7 +46,7 @@ {#if data.cover_image != undefined && data.cover_image !== ''}
not available { diff --git a/frontend/src/lib/yupSchemas.ts b/frontend/src/lib/yupSchemas.ts index d7c8b63..9798626 100644 --- a/frontend/src/lib/yupSchemas.ts +++ b/frontend/src/lib/yupSchemas.ts @@ -65,14 +65,7 @@ export const dataSchema = yup.object({ yup.object({ question: yup.string().required('A question-title is required').max(299), time: yup.number().required().positive('The time has to be positive'), - image: yup - .string() - .nullable() - .matches( - /^(http(|s):\/\/.*(|:)\d*\/api\/v1\/storage\/download\/.{36}--.{36}|https:\/\/i\.imgur\.com\/.{7}.(jpg|png|gif))$|^$/, - "The image-url isn't valid" - ) - .lowercase(), + image: yup.string().nullable().lowercase(), answers: yup.lazy((v) => { if (Array.isArray(v)) { if (typeof v[0].right === 'boolean') { diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index bfb2a15..d3aec73 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -180,7 +180,7 @@