Added local-file-system storage

This commit is contained in:
Mawoka
2022-04-01 22:25:09 +02:00
parent 918d778db0
commit 0ec7c2b519
8 changed files with 76 additions and 15 deletions
+1
View File
@@ -21,6 +21,7 @@ qrcode = "*"
jinja2 = "*" jinja2 = "*"
argon2-cffi = "*" argon2-cffi = "*"
sentry-sdk = "*" sentry-sdk = "*"
aiofiles = "*"
[dev-packages] [dev-packages]
Generated
+12 -4
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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] = []
+7 -3
View File
@@ -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
+10 -1
View File
@@ -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)
+17
View File
@@ -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)
+23 -4
View File
@@ -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