Migrated from image urls to image uuids

This commit is contained in:
Mawoka
2023-05-27 16:37:21 +02:00
parent 025c1fddab
commit 10668e420b
23 changed files with 163 additions and 100 deletions
+32
View File
@@ -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
+21
View File
@@ -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
+3 -3
View File
@@ -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,
+10 -49
View File
@@ -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)
+39 -3
View File
@@ -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)
+6 -1
View File
@@ -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):
+3 -2
View File
@@ -225,7 +225,8 @@
{#if quiz_data.questions[selected_question].image !== null}
<div>
<img
src={quiz_data.questions[selected_question].image}
src="/api/v1/storage/download/{quiz_data.questions[selected_question]
.image}"
class="max-h-[20vh] object-cover mx-auto mb-8 w-auto"
alt="Content for Question"
/>
@@ -320,7 +321,7 @@
<div class="h-[30vh] m-auto w-auto mt-12">
<img
class="max-h-full max-w-full block"
src={quiz_data.cover_image}
src="/api/v1/storage/download/{quiz_data.cover_image}"
alt="Not provided"
/>
</div>
@@ -158,7 +158,7 @@
<div class="h-[20vh] m-auto w-auto">
<img
class="max-h-full max-w-full block"
src={quiz.cover_image}
src="/api/v1/storage/download/{quiz.cover_image}"
alt="Not provided"
loading="lazy"
/>
@@ -302,7 +302,7 @@
{#if visibleImages?.[i]?.[q]}
<img
class="max-h-full max-w-full block"
src={question.image}
src="/api/v1/storage/download/{question.image}"
alt="Not provided"
/>
{/if}
+2 -1
View File
@@ -129,7 +129,8 @@
</svg>
</button>
<img
src={data.questions[selected_question].image}
src="/api/v1/storage/download/{data.questions[selected_question]
.image}"
alt="not available"
class="max-h-64 h-auto w-auto"
/>
+2 -2
View File
@@ -42,7 +42,7 @@
<div
class="dark:bg-gray-700 h-full"
style="background-repeat: no-repeat;background-size: 100% 100%;background-image: {data.background_image
? `url("${data.background_image}")`
? `url("/api/v1/storage/download/${data.background_image}")`
: `unset`}"
>
<div class="flex justify-center pt-10 w-full">
@@ -68,7 +68,7 @@
{#if data.cover_image != undefined && data.cover_image !== ''}
<div class="flex justify-center pt-10 w-full max-h-72 w-full">
<img
src={data.cover_image}
src="/api/v1/storage/download/{data.cover_image}"
alt="not available"
class="max-h-72 h-auto w-auto"
on:contextmenu|preventDefault={() => {
+2 -2
View File
@@ -201,11 +201,11 @@
{#if question.image}
<div class="flex justify-center align-middle pb-0.5">
<img
src={question.image}
src="/api/v1/storage/download/{question.image}"
class="h-10 border rounded-lg"
alt="Not available"
use:tippy={{
content: `<img src='${question.image}' alt='Not available' class='rounded'>`,
content: `<img src='/api/v1/storage/download/${question.image}' alt='Not available' class='rounded'>`,
allowHTML: true
}}
/>
+7 -11
View File
@@ -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);
+4
View File
@@ -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"
}
}
+1 -1
View File
@@ -154,7 +154,7 @@
{#if question.image !== null && game_mode !== 'kahoot'}
<div class="max-h-full">
<img
src={question.image}
src="/api/v1/storage/download/{question.image}"
class="object-cover mx-auto mb-8 max-h-[90%]"
alt="Content for Question"
/>
+5 -1
View File
@@ -15,7 +15,11 @@
{#if cover_image}
<div class="flex justify-center align-middle items-center">
<div class="h-[30vh] m-auto w-auto mt-12">
<img class="max-h-full max-w-full block" src={cover_image} alt="Not provided" />
<img
class="max-h-full max-w-full block"
src="/api/v1/storage/download/{cover_image}"
alt="Not provided"
/>
</div>
</div>
{/if}
+1 -1
View File
@@ -94,7 +94,7 @@
{#if question.image !== null}
<div>
<img
src={question.image}
src="/api/v1/storage/download/{question.image}"
class="max-h-[40vh] object-cover mx-auto mb-8 w-auto"
alt="Content for Question"
/>
@@ -46,7 +46,7 @@
{#if data.cover_image != undefined && data.cover_image !== ''}
<div class="flex justify-center pt-10 w-full max-h-72 w-full">
<img
src={data.cover_image}
src="/api/v1/storage/download/{data.cover_image}"
alt="not available"
class="max-h-72 h-auto w-auto"
on:contextmenu|preventDefault={() => {
+1 -8
View File
@@ -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') {
+1 -1
View File
@@ -180,7 +180,7 @@
<div class="hidden lg:flex w-auto h-full items-center relative">
{#if quiz.cover_image}
<img
src={quiz.cover_image}
src="/api/v1/storage/download/{quiz.cover_image}"
alt="user provided"
loading="lazy"
class="shrink-0 max-w-full max-h-full absolute"
+2 -1
View File
@@ -253,7 +253,8 @@
{#if game_data.questions[selected_question].image !== null}
<div>
<img
src={game_data.questions[selected_question].image}
src="/api/v1/storage/download/{game_data.questions[selected_question]
.image}"
class="max-h-[20vh] object-cover mx-auto mb-8 w-auto"
alt="Content for Question"
/>
@@ -82,7 +82,7 @@
<div class="h-[20vh] m-auto w-auto max-h-[18vh]">
<img
class="max-h-full max-w-full block"
src={quiz.cover_image}
src="/api/v1/storage/download/{quiz.cover_image}"
alt="Not provided"
loading="lazy"
/>
@@ -79,7 +79,7 @@
<div class="h-[15vh] m-auto w-auto my-3">
<img
class="max-h-full max-w-full block"
src={quiz.cover_image}
src="/api/v1/storage/download/{quiz.cover_image}"
alt="Not provided"
/>
</div>
@@ -189,7 +189,11 @@
<!-- </label>-->
{#if question.image}
<span>
<img class="pl-8" src={question.image} alt="Not provided" />
<img
class="pl-8"
src="/api/v1/storage/download/{question.image}"
alt="Not provided"
/>
</span>
{/if}
<p
@@ -31,15 +31,19 @@ def upgrade() -> 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}';")