diff --git a/classquiz/routers/storage.py b/classquiz/routers/storage.py index f6c2d9e..1962a73 100644 --- a/classquiz/routers/storage.py +++ b/classquiz/routers/storage.py @@ -2,8 +2,9 @@ # 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/. from datetime import datetime, timedelta +from tempfile import SpooledTemporaryFile -from fastapi import APIRouter, HTTPException, UploadFile, File, Depends +from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Request, Response from fastapi.responses import StreamingResponse, RedirectResponse from pydantic import BaseModel @@ -19,8 +20,22 @@ settings = settings() router = APIRouter() +def headers_from_storage_item(item: StorageItem) -> dict[str, str]: + base_headers = {"Content-Type": item.mime_type} + if item.hash is not None: + base_headers["X-Hash"] = item.hash.hex() + if item.thumbhash is not None: + base_headers["X-Thumbhash"] = item.thumbhash + if item.alt_text is not None: + base_headers["X-Alt-Text"] = item.alt_text + if item.size != 0: + base_headers["Content-Size"] = str(item.size) + return base_headers + + @router.get("/download/{file_name}") async def download_file(file_name: str): + item = None checked_image_string = check_image_string(file_name) if not checked_image_string[0]: raise HTTPException(status_code=400, detail="Invalid file name") @@ -32,7 +47,7 @@ async def download_file(file_name: str): if file_name is None: file_name = item.id.hex if storage.backend == "s3": - return RedirectResponse(url=await storage.get_url(file_name, 300)) + return RedirectResponse(url=await storage.get_url(file_name, 300), headers=headers_from_storage_item(item)) try: download = await storage.download(file_name) except DownloadingFailedError: @@ -43,13 +58,43 @@ async def download_file(file_name: str): def iter_file(): yield from download + media_type = "image/*" + if item is not None: + media_type = item.mime_type + headers = {"Cache-Control": "public, immutable, max-age=31536000"} + if item is not None: + headers = {**headers, **headers_from_storage_item(item)} + return StreamingResponse( iter_file(), - media_type="image/*", - headers={"Cache-Control": "public, immutable, max-age=31536000"}, + media_type=media_type, + headers=headers, ) +@router.head("/download/{file_name}") +async def download_file_head(file_name: str) -> Response: + checked_image_string = check_image_string(file_name) + if not checked_image_string[0]: + raise HTTPException(status_code=404, detail="Invalid file name") + if checked_image_string[1] is not None: + item = await StorageItem.objects.get_or_none(id=checked_image_string[1]) + if item is None: + raise HTTPException(status_code=404, detail="File not found") + # return PublicStorageItem.from_db_model(item) + storage_file_name = item.storage_path + if storage_file_name is None: + storage_file_name = item.id.hex + resp = Response(status_code=200, headers=headers_from_storage_item(item)) + else: + resp = Response(status_code=200, headers={"Content-Type": "image/*"}) + storage_file_name = file_name + if storage.backend == "s3": + resp.status_code = 307 + resp.headers.append("Location", await storage.get_url(storage_file_name, 300)) + return resp + + @router.post("/") async def upload_file(file: UploadFile = File(), user: User = Depends(get_current_user)) -> PublicStorageItem: if user.storage_used > settings.free_storage_limit: @@ -67,8 +112,33 @@ async def upload_file(file: UploadFile = File(), user: User = Depends(get_curren deleted_at=None, alt_text=None, ) - file_data = await file.read() - await storage.upload(file_name=file_id.hex, file_data=file_data) + await storage.upload(file_name=file_id.hex, file_data=file.file) + await file_obj.save() + await arq.enqueue_job("calculate_hash", file_id.hex) + return PublicStorageItem.from_db_model(file_obj) + + +@router.post("/raw") +async def upload_raw_file(request: Request, user: User = Depends(get_current_user)) -> PublicStorageItem: + if user.storage_used > settings.free_storage_limit: + raise HTTPException(status_code=409, detail="Storage limit reached") + file_id = uuid4() + data_file = SpooledTemporaryFile(max_size=1000) + async for chunk in request.stream(): + data_file.write(chunk) + data_file.seek(0) + file_obj = StorageItem( + id=file_id, + uploaded_at=datetime.now(), + mime_type=request.headers.get("Content-Type"), + hash=None, + user=user, + size=0, + deleted_at=None, + 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 file_obj.save() await arq.enqueue_job("calculate_hash", file_id.hex) return PublicStorageItem.from_db_model(file_obj) diff --git a/classquiz/storage/__init__.py b/classquiz/storage/__init__.py index fc56af4..88469b1 100644 --- a/classquiz/storage/__init__.py +++ b/classquiz/storage/__init__.py @@ -50,8 +50,8 @@ class Storage: """ yield self.instance.download(file_name) - async def upload(self, file_name: str, file_data: BinaryIO) -> None: - return await self.instance.upload(file=file_data, file_name=file_name) + async def upload(self, file_name: str, file_data: BinaryIO, mime_type: str | None = None) -> None: + return await self.instance.upload(file=file_data, file_name=file_name, mime_type=mime_type) async def delete(self, file_names: [str]) -> None: return await self.instance.delete(file_names=file_names) diff --git a/classquiz/storage/local_storage.py b/classquiz/storage/local_storage.py index 894d47e..a352ee8 100644 --- a/classquiz/storage/local_storage.py +++ b/classquiz/storage/local_storage.py @@ -27,7 +27,7 @@ class LocalStorage: except FileNotFoundError: yield None - async def upload(self, file_name: str, data: BinaryIO) -> None: + async def upload(self, file_name: str, data: BinaryIO, mime_type: str | None = None) -> None: async with aiofiles.open(file=os.path.join(self.base_path, file_name), mode="wb") as f: await aioshutil_copyfileobj(data, f) diff --git a/classquiz/storage/s3_storage.py b/classquiz/storage/s3_storage.py index 55d22f1..6a1470d 100644 --- a/classquiz/storage/s3_storage.py +++ b/classquiz/storage/s3_storage.py @@ -112,9 +112,7 @@ class S3Storage: return headers, request_url - async def upload( - self, file: BinaryIO, file_name: str, content_type: str | None = "application/octet-stream" - ) -> None: + async def upload(self, file: BinaryIO, file_name: str, mime_type: str | None = "application/octet-stream") -> None: headers, url = self._generate_aws_signature_v4(method="PUT", path=f"/{file_name}") async with ClientSession() as session, session.put(url, headers=headers, data=file) as resp: if resp.status == 200: diff --git a/frontend/package.json b/frontend/package.json index 6366615..2b0e6ee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,8 @@ "@ckeditor/ckeditor5-theme-lark": "^37.1.0", "@felte/reporter-tippy": "^1.1.5", "@felte/validator-yup": "^1.0.11", + "@ffmpeg/core": "^0.11.0", + "@ffmpeg/ffmpeg": "^0.11.6", "@fontsource/marck-script": "^4.5.11", "@sentry/browser": "^7.48.0", "@sentry/tracing": "^7.48.0", @@ -96,6 +98,7 @@ "typescript": "~5.0.4", "ua-parser-js": "^1.0.35", "vite": "^4.2.2", + "vite-plugin-cross-origin-isolation": "^0.1.6", "vite-plugin-iso-import": "^1.0.0", "yup": "^1.1.1" }, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a4c5017..5836bf9 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -10,6 +10,8 @@ specifiers: '@ckeditor/ckeditor5-theme-lark': ^37.1.0 '@felte/reporter-tippy': ^1.1.5 '@felte/validator-yup': ^1.0.11 + '@ffmpeg/core': ^0.11.0 + '@ffmpeg/ffmpeg': ^0.11.6 '@fontsource/marck-script': ^4.5.11 '@sentry/browser': ^7.48.0 '@sentry/tracing': ^7.48.0 @@ -84,6 +86,7 @@ specifiers: typescript: ~5.0.4 ua-parser-js: ^1.0.35 vite: ^4.2.2 + vite-plugin-cross-origin-isolation: ^0.1.6 vite-plugin-iso-import: ^1.0.0 yup: ^1.1.1 @@ -100,6 +103,8 @@ devDependencies: '@ckeditor/ckeditor5-theme-lark': 37.1.0 '@felte/reporter-tippy': 1.1.5_tippy.js@6.3.7 '@felte/validator-yup': 1.0.11_yup@1.1.1 + '@ffmpeg/core': 0.11.0 + '@ffmpeg/ffmpeg': 0.11.6 '@fontsource/marck-script': 4.5.11 '@sentry/browser': 7.48.0 '@sentry/tracing': 7.48.0 @@ -173,6 +178,7 @@ devDependencies: typescript: 5.0.4 ua-parser-js: 1.0.35 vite: 4.2.2_sass@1.62.0 + vite-plugin-cross-origin-isolation: 0.1.6 vite-plugin-iso-import: 1.0.0_vite@4.2.2 yup: 1.1.1 @@ -939,6 +945,28 @@ packages: yup: 1.1.1 dev: true + /@ffmpeg/core/0.11.0: + resolution: + { + integrity: sha512-9Tt/+2PMpkGPXUK8n6He9G8Y+qR6qmCPSCw9iEKZxHHOvJ9BE/r0Fccj+YgDZTlyu6rXxc9x6EqCaFBIt7qzjA== + } + dev: true + + /@ffmpeg/ffmpeg/0.11.6: + resolution: + { + integrity: sha512-uN8J8KDjADEavPhNva6tYO9Fj0lWs9z82swF3YXnTxWMBoFLGq3LZ6FLlIldRKEzhOBKnkVfA8UnFJuvGvNxcA== + } + engines: { node: '>=12.16.1' } + dependencies: + is-url: 1.2.4 + node-fetch: 2.6.11 + regenerator-runtime: 0.13.11 + resolve-url: 0.2.1 + transitivePeerDependencies: + - encoding + dev: true + /@fontsource/marck-script/4.5.11: resolution: { @@ -3538,6 +3566,13 @@ packages: } dev: true + /is-url/1.2.4: + resolution: + { + integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww== + } + dev: true + /isexe/2.0.0: resolution: { @@ -4017,6 +4052,21 @@ packages: } dev: true + /node-fetch/2.6.11: + resolution: + { + integrity: sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w== + } + engines: { node: 4.x || >=6.0.0 } + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + dependencies: + whatwg-url: 5.0.0 + dev: true + /node-releases/2.0.10: resolution: { @@ -4955,6 +5005,14 @@ packages: protocol-buffers-schema: 3.6.0 dev: true + /resolve-url/0.2.1: + resolution: + { + integrity: sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg== + } + deprecated: https://github.com/lydell/resolve-url#deprecated + dev: true + /resolve/1.22.2: resolution: { @@ -5567,6 +5625,13 @@ packages: engines: { node: '>=6' } dev: true + /tr46/0.0.3: + resolution: + { + integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + } + dev: true + /ts-interface-checker/0.1.13: resolution: { @@ -5724,6 +5789,13 @@ packages: unist-util-stringify-position: 2.0.3 dev: true + /vite-plugin-cross-origin-isolation/0.1.6: + resolution: + { + integrity: sha512-OY0naW9nPUDrKTffYnY7FRYXOgZdHoNwMGpPxUmj/n32mGZi01fcq+J536jkmwGWX7DLT95XBQVHHbrAJzTvrw== + } + dev: true + /vite-plugin-iso-import/1.0.0_vite@4.2.2: resolution: { @@ -5799,6 +5871,23 @@ packages: pbf: 3.2.1 dev: true + /webidl-conversions/3.0.1: + resolution: + { + integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + } + dev: true + + /whatwg-url/5.0.0: + resolution: + { + integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + } + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + dev: true + /which-module/2.0.0: resolution: { diff --git a/frontend/src/lib/editor/MediaComponent.svelte b/frontend/src/lib/editor/MediaComponent.svelte new file mode 100644 index 0000000..1533ddd --- /dev/null +++ b/frontend/src/lib/editor/MediaComponent.svelte @@ -0,0 +1,56 @@ + + + + +{#await media} +
Placeholder
+{:then data} + {#if type === 'img'} +Video!
+ + {:else} +Unknown media type
+ {/if} +{/await} diff --git a/frontend/src/lib/editor/card.svelte b/frontend/src/lib/editor/card.svelte index d12a767..fc9d520 100644 --- a/frontend/src/lib/editor/card.svelte +++ b/frontend/src/lib/editor/card.svelte @@ -12,14 +12,15 @@ import Spinner from '../Spinner.svelte'; // import { createTippy } from 'svelte-tippy'; import { getLocalization } from '$lib/i18n'; + // import MediaComponent from "$lib/editor/MediaComponent.svelte"; const { t } = getLocalization(); /* const tippy = createTippy({ - arrow: true, - animation: 'perspective-subtle', - placement: 'top' - });*/ + arrow: true, + animation: 'perspective-subtle', + placement: 'top' + });*/ export let data: EditorData; export let selected_question: number; @@ -49,6 +50,17 @@ selected_question; set_unique(); } + let image_url = ''; + + const update_image_url = () => { + image_url = `/api/v1/storage/download/${data.questions[selected_question].image}`; + console.log('updated!'); + }; + $: { + update_image_url(); + selected_question; + data.questions; + } /* if (typeof data.questions[selected_question].type !== QuizQuestionType) { console.log(data.questions[selected_question].type !== QuizQuestionType.ABCD || data.questions[selected_question].type !== QuizQuestionType.RANGE) @@ -126,12 +138,9 @@ /> -| Speed | +Progress | +Time elapsed | +Time remaining | +
|---|---|---|---|
| {stats.speed} | +{Math.round(stats.progress * 100)}% | +{upload_stats.time_elapsed.toFixed(2)}s | +{(stats.time_elapsed / stats.progress - upload_stats.time_elapsed).toFixed( + 2 + )}s | +
| Progress | +Time elapsed | +Time remaining | +
|---|---|---|
| {Math.round(upload_stats.progress * 100)}% | +{stats.time_elapsed.toFixed(2)}s | +{(stats.time_elapsed / stats.progress - stats.time_elapsed).toFixed(2)}s | +