diff --git a/Pipfile b/Pipfile index d35597f..6f5407e 100644 --- a/Pipfile +++ b/Pipfile @@ -40,6 +40,7 @@ minio = "*" xxhash = "*" arq = "*" thumbhash-python = "==1.0.0" +python-magic = "*" [dev-packages] coverage = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 137e927..5ab1769 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "dc7977c3d6c0c72f41144ddb7bc0821c1bd34c856cbf2a93077c809e6b7a9048" + "sha256": "c3de18e25234c98fd1b33d4df4464fd6e05a485416c76eb6c4f64e78c497af64" }, "pipfile-spec": 6, "requires": { @@ -1357,6 +1357,14 @@ "index": "pypi", "version": "==3.3.0" }, + "python-magic": { + "hashes": [ + "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", + "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3" + ], + "index": "pypi", + "version": "==0.4.27" + }, "python-multipart": { "hashes": [ "sha256:e9925a80bb668529f1b67c7fdb0a5dacdd7cbfc6fb0bff3ea443fe22bdd62132", diff --git a/classquiz/db/models.py b/classquiz/db/models.py index ace21a3..879ee60 100644 --- a/classquiz/db/models.py +++ b/classquiz/db/models.py @@ -8,6 +8,7 @@ from datetime import datetime from typing import Optional import ormar +from ormar import ReferentialAction from pydantic import BaseModel, Json, validator from enum import Enum from . import metadata, database @@ -58,7 +59,7 @@ class FidoCredentials(ormar.Model): id: bytes = ormar.LargeBinary(max_length=256) public_key: bytes = ormar.LargeBinary(max_length=256) sign_count: int = ormar.Integer() - user: Optional[User] = ormar.ForeignKey(User) + user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) class Meta: tablename = "fido_credentials" @@ -68,7 +69,7 @@ class FidoCredentials(ormar.Model): class ApiKey(ormar.Model): key: str = ormar.String(max_length=48, min_length=48, primary_key=True) - user: Optional[User] = ormar.ForeignKey(User) + user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) class Meta: tablename = "api_keys" @@ -82,7 +83,7 @@ class UserSession(ormar.Model): """ id: uuid.UUID = ormar.UUID(primary_key=True, default=uuid.uuid4()) - user: Optional[User] = ormar.ForeignKey(User) + user: Optional[User] = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) session_key: str = ormar.String(unique=True, max_length=64) created_at: datetime = ormar.DateTime(default=datetime.now()) ip_address: str = ormar.String(max_length=100, nullable=True) @@ -181,7 +182,7 @@ class Quiz(ormar.Model): description: str = ormar.Text(nullable=True) created_at: datetime = ormar.DateTime(default=datetime.now()) updated_at: datetime = ormar.DateTime(default=datetime.now()) - user_id: uuid.UUID = ormar.ForeignKey(User) + user_id: uuid.UUID = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) questions: Json[list[QuizQuestion]] = ormar.JSON(nullable=False) imported_from_kahoot: Optional[bool] = ormar.Boolean(default=False, nullable=True) cover_image: Optional[str] = ormar.Text(nullable=True, unique=False) @@ -307,8 +308,8 @@ class GameInLobby(BaseModel): class GameResults(ormar.Model): id: uuid.UUID = ormar.UUID(primary_key=True) - quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz) - user: uuid.UUID | User = ormar.ForeignKey(User) + quiz: uuid.UUID | Quiz = ormar.ForeignKey(Quiz, ondelete=ReferentialAction.CASCADE) + user: uuid.UUID | User = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) timestamp: datetime = ormar.DateTime(default=datetime.now(), nullable=False) player_count: int = ormar.Integer(nullable=False, default=0) note: str | None = ormar.Text(nullable=True) @@ -334,7 +335,7 @@ class QuizTivity(ormar.Model): id: uuid.UUID = ormar.UUID(primary_key=True) title: str = ormar.Text(nullable=False) created_at: datetime = ormar.DateTime(nullable=False, server_default=func.now()) - user: User | None = ormar.ForeignKey(User) + user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) pages: list[QuizTivityPage] = ormar.JSON(nullable=False) class Meta: @@ -347,8 +348,8 @@ class QuizTivityShare(ormar.Model): id: uuid.UUID = ormar.UUID(primary_key=True) name: str | None = ormar.Text(nullable=True) expire_at: datetime | None = ormar.DateTime(nullable=True) - quiztivity: QuizTivity | None = ormar.ForeignKey(QuizTivity) - user: User | None = ormar.ForeignKey(User) + quiztivity: QuizTivity | None = ormar.ForeignKey(QuizTivity, ondelete=ReferentialAction.CASCADE) + user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.CASCADE) class Meta: tablename = "quiztivityshares" @@ -386,7 +387,7 @@ class StorageItem(ormar.Model): uploaded_at: datetime = ormar.DateTime(nullable=False, default=datetime.now()) mime_type: str = ormar.Text(nullable=False) hash: bytes | None = ormar.LargeBinary(nullable=True, min_length=16, max_length=16) - user: User | None = ormar.ForeignKey(User) + user: User | None = ormar.ForeignKey(User, ondelete=ReferentialAction.SET_NULL) size: int = ormar.BigInteger(nullable=False) storage_path: str | None = ormar.Text(nullable=True) deleted_at: datetime | None = ormar.DateTime(nullable=True, default=None) diff --git a/classquiz/helpers/__init__.py b/classquiz/helpers/__init__.py index e725b02..622ad0e 100644 --- a/classquiz/helpers/__init__.py +++ b/classquiz/helpers/__init__.py @@ -65,12 +65,15 @@ async def generate_spreadsheet(quiz_results: dict, quiz: Quiz, player_fields: di worksheet.write(i + 1, 1, question["time"]) try: - async with ClientSession() as session, session.get(question["image"]) as response: - img_data = BytesIO(await response.read()) - worksheet.insert_image(i + 1, 2, question["image"], {"image_data": img_data}) - image = Image.open(img_data) - worksheet.set_row(i + 1, image.height) - worksheet.set_column(2, 2, image.width) + async with ClientSession() as session, session.get( + f"{settings.root_address}/api/v1/storage/download/{question['image']}" + ) as response: + if "image" in response.headers.get("Content-Type"): + img_data = BytesIO(await response.read()) + worksheet.insert_image(i + 1, 2, question["image"], {"image_data": img_data}) + image = Image.open(img_data) + worksheet.set_row(i + 1, image.height) + worksheet.set_column(2, 2, image.width) except TypeError: pass answer_amount = len(answer_data) diff --git a/classquiz/routers/eximport.py b/classquiz/routers/eximport.py index 56758b0..294548b 100644 --- a/classquiz/routers/eximport.py +++ b/classquiz/routers/eximport.py @@ -1,6 +1,7 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +import io import json import uuid from datetime import datetime @@ -10,10 +11,11 @@ from aiohttp import ClientSession from fastapi import APIRouter, Depends, File, UploadFile, HTTPException from fastapi.responses import StreamingResponse from classquiz.auth import get_current_user -from classquiz.config import storage, settings -from classquiz.db.models import Quiz, User +from classquiz.config import storage, settings, arq +from classquiz.db.models import Quiz, User, StorageItem import gzip import urllib.parse +import magic router = APIRouter() settings = settings() @@ -49,7 +51,9 @@ async def export_quiz(quiz_id: uuid.UUID, user: User = Depends(get_current_user) for image_key in image_urls.keys(): bin_data = bin_data + image_delimiter + str(image_key).encode("utf-8") + image_index_delimiter image_data = None - async with ClientSession() as session, session.get(image_urls[image_key]) as resp: + async with ClientSession() as session, session.get( + f"{settings.root_address}/api/v1/storage/download/{image_urls[image_key]}" + ) as resp: image_data = await resp.read() bin_data = bin_data + image_data @@ -68,6 +72,8 @@ async def export_quiz(quiz_id: uuid.UUID, user: User = Depends(get_current_user) @router.post("/") async def import_quiz(file: UploadFile = File(), user: User = Depends(get_current_user)): + if user.storage_used > settings.free_storage_limit: + raise HTTPException(status_code=409, detail="Storage limit reached") data = await file.read() [split_data, images] = data.split(quiz_delimiter) decompressed_quiz = gzip.decompress(split_data) @@ -75,15 +81,32 @@ async def import_quiz(file: UploadFile = File(), user: User = Depends(get_curren image_splits = images.split(image_delimiter) quiz_id = uuid.uuid4() image_urls = {} + print(len(data)) for image_split in image_splits: res = image_split.split(image_index_delimiter) if len(res) != 2: continue [index, image_data] = res + print(len(image_data)) + img_data = io.BytesIO(image_data) + mime_type = magic.from_buffer(img_data.read(2048), mime=True) + print(mime_type) index = int(index.decode("utf-8")) - image_name = f"{quiz_id}--{uuid.uuid4()}" - await storage.upload(file_name=image_name, file_data=image_data) - image = f"{settings.root_address}/api/v1/storage/download/{image_name}" + file_id = uuid.uuid4() + file_obj = StorageItem( + id=file_id, + uploaded_at=datetime.now(), + mime_type=mime_type, + hash=None, + user=user, + size=0, + deleted_at=None, + alt_text=None, + ) + await storage.upload(file_name=file_id.hex, file_data=img_data, mime_type=mime_type) + await file_obj.save() + await arq.enqueue_job("calculate_hash", file_id.hex) + image = file_id.hex image_urls[index] = image quiz_dict["created_at"] = datetime.fromisoformat(quiz_dict["created_at"]) quiz_dict["updated_at"] = datetime.fromisoformat(quiz_dict["updated_at"]) diff --git a/classquiz/routers/quiz.py b/classquiz/routers/quiz.py index e15287f..89bd29d 100644 --- a/classquiz/routers/quiz.py +++ b/classquiz/routers/quiz.py @@ -209,7 +209,9 @@ async def delete_quiz(quiz_id: str, user: User = Depends(get_current_user)): for question in quiz.questions: try: if question["image"] is not None and not str(question["image"]).startswith("https://i.imgur.com/"): - pics_to_delete.append(pic_name_regex.match(question["image"]).group(1)) + old_image_to_delete = pic_name_regex.match(question["image"]) + if old_image_to_delete is not None: + pics_to_delete.append(old_image_to_delete.group(1)) except KeyError: pass if len(pics_to_delete) != 0: diff --git a/classquiz/routers/storage.py b/classquiz/routers/storage.py index d40e073..35672fc 100644 --- a/classquiz/routers/storage.py +++ b/classquiz/routers/storage.py @@ -131,7 +131,7 @@ async def upload_file(file: UploadFile = File(), user: User = Depends(get_curren deleted_at=None, alt_text=None, ) - await storage.upload(file_name=file_id.hex, file_data=file.file) + await storage.upload(file_name=file_id.hex, file_data=file.file, mime_type=file.content_type) await file_obj.save() await arq.enqueue_job("calculate_hash", file_id.hex) return PublicStorageItem.from_db_model(file_obj) @@ -157,7 +157,9 @@ async def upload_raw_file(request: Request, user: User = Depends(get_current_use alt_text=None, ) # https://github.com/VirusTotal/vt-py/issues/119#issuecomment-1261246867 - await storage.upload(file_name=file_id.hex, file_data=data_file._file) + await storage.upload( + file_name=file_id.hex, file_data=data_file._file, mime_type=request.headers.get("Content-Type") + ) await file_obj.save() await arq.enqueue_job("calculate_hash", file_id.hex) return PublicStorageItem.from_db_model(file_obj) diff --git a/frontend/src/lib/admin.svelte b/frontend/src/lib/admin.svelte index 0370c5d..826e737 100644 --- a/frontend/src/lib/admin.svelte +++ b/frontend/src/lib/admin.svelte @@ -227,6 +227,7 @@