✨ Added local-file-system storage
This commit is contained in:
@@ -21,6 +21,7 @@ qrcode = "*"
|
|||||||
jinja2 = "*"
|
jinja2 = "*"
|
||||||
argon2-cffi = "*"
|
argon2-cffi = "*"
|
||||||
sentry-sdk = "*"
|
sentry-sdk = "*"
|
||||||
|
aiofiles = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
|
|
||||||
|
|||||||
Generated
+12
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "0411374cb38f6ed3199ec87e5d1717b598f8af20c46cef66377af0c719c4b988"
|
"sha256": "99a926c60fbb456ac477587d91947b39dfce66d39d4a2b6a35d1c41c04b4e32f"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {
|
"requires": {
|
||||||
@@ -16,6 +16,14 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"default": {
|
"default": {
|
||||||
|
"aiofiles": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:7a973fc22b29e9962d0897805ace5856e6a566ab1f0c8e5c91ff6c866519c937",
|
||||||
|
"sha256:8334f23235248a3b2e83b2c3a78a22674f39969b96397126cc93664d9a901e59"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.8.0"
|
||||||
|
},
|
||||||
"aiohttp": {
|
"aiohttp": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3",
|
"sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3",
|
||||||
@@ -291,7 +299,7 @@
|
|||||||
"sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597",
|
"sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597",
|
||||||
"sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"
|
"sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"
|
||||||
],
|
],
|
||||||
"markers": "python_full_version >= '3.5.0'",
|
"markers": "python_version >= '3.5'",
|
||||||
"version": "==2.0.12"
|
"version": "==2.0.12"
|
||||||
},
|
},
|
||||||
"click": {
|
"click": {
|
||||||
@@ -536,7 +544,7 @@
|
|||||||
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
|
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
|
||||||
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
|
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
|
||||||
],
|
],
|
||||||
"markers": "python_full_version >= '3.5.0'",
|
"markers": "python_version >= '3.5'",
|
||||||
"version": "==3.3"
|
"version": "==3.3"
|
||||||
},
|
},
|
||||||
"jinja2": {
|
"jinja2": {
|
||||||
@@ -957,7 +965,7 @@
|
|||||||
"sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663",
|
"sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663",
|
||||||
"sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"
|
"sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"
|
||||||
],
|
],
|
||||||
"markers": "python_full_version >= '3.5.0'",
|
"markers": "python_version >= '3.5'",
|
||||||
"version": "==1.2.0"
|
"version": "==1.2.0"
|
||||||
},
|
},
|
||||||
"sqlalchemy": {
|
"sqlalchemy": {
|
||||||
|
|||||||
+5
-2
@@ -26,8 +26,11 @@ class Settings(BaseSettings):
|
|||||||
# storage_backend
|
# storage_backend
|
||||||
storage_backend: str | None = "deta"
|
storage_backend: str | None = "deta"
|
||||||
# if storage_backend == "deta":
|
# if storage_backend == "deta":
|
||||||
deta_project_key: str
|
deta_project_key: str | None
|
||||||
deta_project_id: str
|
deta_project_id: str | None
|
||||||
|
|
||||||
|
# if storage_backend == "local":
|
||||||
|
storage_path: str | None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ async def import_quiz(quiz_id: str, user: User) -> Quiz | str:
|
|||||||
quiz_questions: list[dict] = []
|
quiz_questions: list[dict] = []
|
||||||
quiz_id = uuid.uuid4()
|
quiz_id = uuid.uuid4()
|
||||||
storage = Storage(backend=settings.storage_backend, deta_key=settings.deta_project_key,
|
storage = Storage(backend=settings.storage_backend, deta_key=settings.deta_project_key,
|
||||||
deta_id=settings.deta_project_id)
|
deta_id=settings.deta_project_id, storage_path=settings.storage_path)
|
||||||
|
|
||||||
for q in quiz.kahoot.questions:
|
for q in quiz.kahoot.questions:
|
||||||
answers: list[QuizAnswer] = []
|
answers: list[QuizAnswer] = []
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from classquiz.config import settings
|
from classquiz.config import settings
|
||||||
import io
|
import re
|
||||||
from classquiz.storage import Storage
|
from classquiz.storage import Storage
|
||||||
|
|
||||||
settings = settings()
|
settings = settings()
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
file_regex = r"^[a-z0-9]{8}-[a-z0-9-]{27}--[a-z0-9-]{36}$"
|
||||||
|
|
||||||
|
|
||||||
@router.get('/download/{file_name}')
|
@router.get('/download/{file_name}')
|
||||||
async def download_file(file_name: str):
|
async def download_file(file_name: str):
|
||||||
storage = Storage(backend=settings.storage_backend, deta_key=settings.deta_project_key,
|
storage = Storage(backend=settings.storage_backend, deta_key=settings.deta_project_key,
|
||||||
deta_id=settings.deta_project_id)
|
deta_id=settings.deta_project_id, storage_path=settings.storage_path)
|
||||||
download = await storage.download(file_name)
|
download = await storage.download(file_name)
|
||||||
|
if not re.match(file_regex, file_name):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||||
|
|
||||||
def iter_file():
|
def iter_file():
|
||||||
yield from download
|
yield from download
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
from .deta_storage import DetaStorage
|
from .deta_storage import DetaStorage
|
||||||
|
from .local_storage import LocalStorage
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
|
|
||||||
class Storage:
|
class Storage:
|
||||||
def __init__(self, backend: str, deta_key: str | None, deta_id: str | None):
|
def __init__(self, backend: str, deta_key: str | None, deta_id: str | None, storage_path: str | None):
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
self.deta_key = deta_key
|
self.deta_key = deta_key
|
||||||
self.deta_id = deta_id
|
self.deta_id = deta_id
|
||||||
self.deta_base_url = f"https://drive.deta.sh/v1/{deta_id}/classquiz"
|
self.deta_base_url = f"https://drive.deta.sh/v1/{deta_id}/classquiz"
|
||||||
self.deta_instance = DetaStorage(deta_base_url=self.deta_base_url, deta_key=self.deta_key, deta_id=self.deta_id)
|
self.deta_instance = DetaStorage(deta_base_url=self.deta_base_url, deta_key=self.deta_key, deta_id=self.deta_id)
|
||||||
|
self.local_instance = LocalStorage(base_path=storage_path)
|
||||||
if backend == "deta":
|
if backend == "deta":
|
||||||
if deta_key is None or deta_id is None:
|
if deta_key is None or deta_id is None:
|
||||||
raise ValueError("deta_key and deta_id must be provided")
|
raise ValueError("deta_key and deta_id must be provided")
|
||||||
|
if backend == "local":
|
||||||
|
if storage_path is None:
|
||||||
|
raise ValueError("storage_path must be provided")
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError(f"Backend {backend} not implemented")
|
raise NotImplementedError(f"Backend {backend} not implemented")
|
||||||
|
|
||||||
@@ -19,7 +24,11 @@ class Storage:
|
|||||||
if self.backend == "deta":
|
if self.backend == "deta":
|
||||||
return await self.deta_instance.download(
|
return await self.deta_instance.download(
|
||||||
file_name)
|
file_name)
|
||||||
|
elif self.backend == "local":
|
||||||
|
return await self.local_instance.get_file(file_name)
|
||||||
|
|
||||||
async def upload(self, file_name: str, file_data: bytes) -> None:
|
async def upload(self, file_name: str, file_data: bytes) -> None:
|
||||||
if self.backend == "deta":
|
if self.backend == "deta":
|
||||||
return await self.deta_instance.upload(file=file_data, file_name=file_name)
|
return await self.deta_instance.upload(file=file_data, file_name=file_name)
|
||||||
|
elif self.backend == "local":
|
||||||
|
return await self.local_instance.write_file(file_name=file_name, data=file_data)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
import aiofiles
|
||||||
|
|
||||||
|
|
||||||
|
class LocalStorage:
|
||||||
|
def __init__(self, base_path: str):
|
||||||
|
self.base_path = base_path
|
||||||
|
|
||||||
|
async def get_file(self, file_name: str) -> io.BytesIO:
|
||||||
|
async with aiofiles.open(file=os.path.join(self.base_path, file_name), mode='rb') as f:
|
||||||
|
return io.BytesIO(await f.read())
|
||||||
|
|
||||||
|
async def write_file(self, file_name: str, data: bytes) -> None:
|
||||||
|
async with aiofiles.open(file=os.path.join(self.base_path, file_name), mode='wb') as f:
|
||||||
|
await f.write(data)
|
||||||
|
|
||||||
@@ -22,10 +22,6 @@
|
|||||||
<p>Since ClassQuiz is open-source, it can also be self-hosted.</p>
|
<p>Since ClassQuiz is open-source, it can also be self-hosted.</p>
|
||||||
|
|
||||||
|
|
||||||
<h2>Warning</h2>
|
|
||||||
<p>This "warning" is just temporary, because you need a <a href='https://deta.sh'>Deta</a> account (which is free)
|
|
||||||
to store and serve the images getting imported with the kahoot-import function. I am planning to add more backends like s3 or the local file system. Untill then, Deta is needed. </p>
|
|
||||||
|
|
||||||
<h2>Requirements</h2>
|
<h2>Requirements</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href='https://docker.com'>Docker</a></li>
|
<li><a href='https://docker.com'>Docker</a></li>
|
||||||
@@ -51,6 +47,21 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Configuration</h2>
|
<h2>Configuration</h2>
|
||||||
|
<h3>Storage Provider</h3>
|
||||||
|
<p>
|
||||||
|
You'll have to set up a storage provider for some pictures (these getting imported from KAHOOT!). For now, you
|
||||||
|
can use <a href='https://deta.sh'>Deta</a> or the local filesystem. Please note that I would <b>NOT</b> use it
|
||||||
|
because of these funny path-things. I tried to prevent these attacks, but i really wouldn't trust it.
|
||||||
|
You'll have to set the <code>STORAGE_BACKEND</code>-environment-variable to either <code>deta</code> or <code>local</code>.
|
||||||
|
</p>
|
||||||
|
<h4>If you chose Deta...</h4>
|
||||||
|
<p>
|
||||||
|
...you'll also have to set the <code>DETA_PROJECT_KEY</code> and the <code>DETA_PROJECT_ID</code>.
|
||||||
|
</p>
|
||||||
|
<h4>If you chose the local filesystem...</h4>
|
||||||
|
<p>
|
||||||
|
...you'll have to set the <code>STORAGE_PATH</code> enviromnent variable. The path must be absolute (so start with a <code>/</code>).
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Before you can start your stack, you have to set some environment-variables in your
|
Before you can start your stack, you have to set some environment-variables in your
|
||||||
<code>docker-compose.yml</code>.
|
<code>docker-compose.yml</code>.
|
||||||
@@ -92,6 +103,14 @@ services:
|
|||||||
SECRET_KEY: "ghfvfgjgvjgvbh" # openssl rand -hex 32
|
SECRET_KEY: "ghfvfgjgvjgvbh" # openssl rand -hex 32
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: 30
|
ACCESS_TOKEN_EXPIRE_MINUTES: 30
|
||||||
HCAPTCHA_KEY: "" # Private hCaptcha key for verification
|
HCAPTCHA_KEY: "" # Private hCaptcha key for verification
|
||||||
|
STORAGE_BACKEND: "deta" # MUST BE EITHER "deta" OR "local"
|
||||||
|
|
||||||
|
# If STORAGE_BACKEND is "deta"
|
||||||
|
DETA_PROJECT_KEY: "YOUR_DETA_PROJECT_KEY"
|
||||||
|
DETA_PROJECT_ID: "YOUR_DETA_PROJECT_ID"
|
||||||
|
|
||||||
|
# If STORAGE_BACKEND is "local"
|
||||||
|
STORAGE_PATH: "/var/storage"
|
||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
Reference in New Issue
Block a user