diff --git a/Pipfile b/Pipfile
index 4aad229..0fe471e 100644
--- a/Pipfile
+++ b/Pipfile
@@ -30,6 +30,7 @@ pillow = ">=9.1.1"
authlib = "*"
httpx = "*"
itsdangerous = "*"
+puremagic = "*"
[dev-packages]
coverage = "*"
diff --git a/Pipfile.lock b/Pipfile.lock
index 5712a07..80bee32 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "259996d9979f6434bbd2a43c1146e10b485bca6df5fdb9d37319883800c56e33"
+ "sha256": "fe59e46268415d1b9088e7e27b599d88913b762a99d940336ec2e01e8e122378"
},
"pipfile-spec": 6,
"requires": {
@@ -250,11 +250,11 @@
},
"certifi": {
"hashes": [
- "sha256:9c5705e395cd70084351dd8ad5c41e65655e08ce46f2ec9cf6c2c08390f71eb7",
- "sha256:f1d53542ee8cbedbe2118b5686372fb33c297fcd6379b050cca0ef13a597382a"
+ "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d",
+ "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"
],
"markers": "python_version >= '3.6'",
- "version": "==2022.5.18.1"
+ "version": "==2022.6.15"
},
"cffi": {
"hashes": [
@@ -883,6 +883,14 @@
],
"version": "==2.9.3"
},
+ "puremagic": {
+ "hashes": [
+ "sha256:3d5df26cc7ec9aebbf842a09115a2fa85dc59ea6414fa568572c44775d796cbc",
+ "sha256:40e32752827f2d0cea7e6f11454fab2b4bd440582af83d8a47bf403ab42364fa"
+ ],
+ "index": "pypi",
+ "version": "==1.14"
+ },
"pyasn1": {
"hashes": [
"sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359",
@@ -1084,11 +1092,11 @@
},
"setuptools": {
"hashes": [
- "sha256:5a844ad6e190dccc67d6d7411d119c5152ce01f7c76be4d8a1eaa314501bba77",
- "sha256:bf8a748ac98b09d32c9a64a995a6b25921c96cc5743c1efa82763ba80ff54e91"
+ "sha256:990a4f7861b31532871ab72331e755b5f14efbe52d336ea7f6118144dd478741",
+ "sha256:c1848f654aea2e3526d17fc3ce6aeaa5e7e24e66e645b5be2171f3f6b4e5a178"
],
"markers": "python_version >= '3.7'",
- "version": "==62.4.0"
+ "version": "==62.6.0"
},
"six": {
"hashes": [
@@ -1750,7 +1758,7 @@
"sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
"sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"
],
- "markers": "python_version < '3.11'",
+ "markers": "python_version >= '3.7'",
"version": "==2.0.1"
},
"virtualenv": {
diff --git a/classquiz/helpers/hashcash.py b/classquiz/helpers/hashcash.py
new file mode 100644
index 0000000..612a4bb
--- /dev/null
+++ b/classquiz/helpers/hashcash.py
@@ -0,0 +1,167 @@
+#!/usr/bin/env python2.3
+"""Implement Hashcash version 1 protocol in Python
++-------------------------------------------------------+
+| Written by David Mertz; released to the Public Domain |
++-------------------------------------------------------+
+
+Double spend database not implemented in this module, but stub
+for callbacks is provided in the 'check()' function
+
+The function 'check()' will validate hashcash v1 and v0 tokens, as well as
+'generalized hashcash' tokens generically. Future protocol version are
+treated as generalized tokens (should a future version be published w/o
+this module being correspondingly updated).
+
+A 'generalized hashcash' is implemented in the '_mint()' function, with the
+public function 'mint()' providing a wrapper for actual hashcash protocol.
+The generalized form simply finds a suffix that creates zero bits in the
+hash of the string concatenating 'challenge' and 'suffix' without specifying
+any particular fields or delimiters in 'challenge'. E.g., you might get:
+
+ >>> from hashcash import mint, _mint
+ >>> mint('foo', bits=16)
+ '1:16:040922:foo::+ArSrtKd:164b3'
+ >>> _mint('foo', bits=16)
+ '9591'
+ >>> import hashlib
+ >>> hashlib.sha1(('foo9591').encode()).hexdigest()
+ '0000de4c9b27cec9b20e2094785c1c58eaf23948'
+ >>> hashlib.sha1(('1:16:040922:foo::+ArSrtKd:164b3').encode()).hexdigest()
+ '0000a9fe0c6db2efcbcab15157735e77c0877f34'
+
+Notice that '_mint()' behaves deterministically, finding the same suffix
+every time it is passed the same arguments. 'mint()' incorporates a random
+salt in stamps (as per the hashcash v.1 protocol).
+"""
+import sys
+from string import ascii_letters
+from math import ceil, floor
+import hashlib
+from random import choice
+from time import strftime, localtime, time
+
+
+class HCNotValid(BaseException):
+ message: str
+
+
+DAYS = 60 * 60 * 24 # Seconds in a day
+tries = [0] # Count hashes performed for benchmark
+
+
+def mint(resource, bits=20, now=None, ext="", saltchars=8, stamp_seconds=False):
+ """Mint a new hashcash stamp for 'resource' with 'bits' of collision
+
+ 20 bits of collision is the default.
+
+ 'ext' lets you add your own extensions to a minted stamp. Specify an
+ extension as a string of form 'name1=2,3;name2;name3=var1=2,2,val'
+ FWIW, urllib.urlencode(dct).replace('&',';') comes close to the
+ hashcash extension format.
+
+ 'saltchars' specifies the length of the salt used; this version defaults
+ 8 chars, rather than the C version's 16 chars. This still provides about
+ 17 million salts per resource, per timestamp, before birthday paradox
+ collisions occur. Really paranoid users can use a larger salt though.
+
+ 'stamp_seconds' lets you add the option time elements to the datestamp.
+ If you want more than just day, you get all the way down to seconds,
+ even though the spec also allows hours/minutes without seconds.
+ """
+ ver = "1"
+ now = now or time()
+ if stamp_seconds:
+ ts = strftime("%y%m%d%H%M%S", localtime(now))
+ else:
+ ts = strftime("%y%m%d", localtime(now))
+ challenge = "%s:" * 6 % (ver, bits, ts, resource, ext, _salt(saltchars))
+ return challenge + _mint(challenge, bits)
+
+
+def _salt(l):
+ "Return a random string of length 'l'"
+ alphabet = ascii_letters + "+/="
+ return "".join([choice(alphabet) for _ in [None] * l])
+
+
+def _mint(challenge, bits):
+ """Answer a 'generalized hashcash' challenge'
+
+ Hashcash requires stamps of form 'ver:bits:date:res:ext:rand:counter'
+ This internal function accepts a generalized prefix 'challenge',
+ and returns only a suffix that produces the requested SHA leading zeros.
+
+ NOTE: Number of requested bits is rounded up to the nearest multiple of 4
+ """
+ counter = 0
+ hex_digits = int(ceil(bits / 4.0))
+ zeros = "0" * hex_digits
+ while 1:
+ digest = hashlib.sha1((challenge + hex(counter)[2:]).encode()).hexdigest()
+ if digest[:hex_digits] == zeros:
+ tries[0] = counter
+ return hex(counter)[2:]
+ counter += 1
+
+
+def check(stamp, resource=None, bits=None, check_expiration=None, ds_callback=None):
+ """Check whether a stamp is valid
+
+ Optionally, the stamp may be checked for a specific resource, and/or
+ it may require a minimum bit value, and/or it may be checked for
+ expiration, and/or it may be checked for double spending.
+
+ If 'check_expiration' is specified, it should contain the number of
+ seconds old a date field may be. Indicating days might be easier in
+ many cases, e.g.
+
+ >>> from hashcash import DAYS
+ >>> check(stamp, check_expiration=28*DAYS)
+
+ NOTE: Every valid (version 1) stamp must meet its claimed bit value
+ NOTE: Check floor of 4-bit multiples (overly permissive in acceptance)
+ """
+ if stamp.startswith("0:"): # Version 0
+ try:
+ date, res, suffix = stamp[2:].split(":")
+ except ValueError:
+ return False
+ if resource is not None and resource != res:
+ return False
+ elif check_expiration is not None:
+ good_until = strftime("%y%m%d%H%M%S", localtime(time() - check_expiration))
+ if date < good_until:
+ return False
+ elif callable(ds_callback) and ds_callback(stamp):
+ return False
+ elif type(bits) is not int:
+ return True
+ else:
+ hex_digits = int(floor(bits / 4))
+ return hashlib.sha1((stamp).encode()).hexdigest().startswith("0" * hex_digits)
+ elif stamp.startswith("1:"): # Version 1
+ try:
+ claim, date, res, ext, rand, counter = stamp[2:].split(":")
+ except ValueError:
+ return False
+ if resource is not None and resource != res:
+ return False
+ elif type(bits) is int and bits > int(claim):
+ return False
+ elif check_expiration is not None:
+ good_until = strftime("%y%m%d%H%M%S", localtime(time() - check_expiration))
+ if date < good_until:
+ return False
+ elif callable(ds_callback) and ds_callback(stamp):
+ return False
+ else:
+ hex_digits = int(floor(int(claim) / 4))
+ return hashlib.sha1((stamp).encode()).hexdigest().startswith("0" * hex_digits)
+ else: # Unknown ver or generalized hashcash
+ if type(bits) is not int:
+ return True
+ elif resource is not None and stamp.find(resource) < 0:
+ return False
+ else:
+ hex_digits = int(floor(bits / 4))
+ return hashlib.sha1((stamp).encode()).hexdigest().startswith("0" * hex_digits)
diff --git a/classquiz/routers/editor.py b/classquiz/routers/editor.py
index e6eaca5..ca2efab 100644
--- a/classquiz/routers/editor.py
+++ b/classquiz/routers/editor.py
@@ -11,18 +11,21 @@ from pydantic import BaseModel
from classquiz.config import settings, redis, storage, meilisearch
from classquiz.db.models import Quiz, QuizInput, User
+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
+from classquiz.helpers import get_meili_data, check_hashcash
from classquiz.storage.errors import DeletionFailedError
settings = settings()
router = APIRouter()
+allowed_image_extensions = [".gif", ".jpg", ".jpeg", ".png", ".svg", ".webp"]
+
class InitEditorResponse(BaseModel):
token: str
@@ -60,21 +63,44 @@ async def init_editor(edit: bool, quiz_id: Optional[UUID] = None, user: User = D
return InitEditorResponse(token=edit_id)
+class GetPowData(BaseModel):
+ data: str
+
+
+@router.get("/pow", response_model=GetPowData)
+async def get_pow_data(edit_id: str):
+ session_data = await redis.get(f"edit_session:{edit_id}")
+ if session_data is None:
+ raise HTTPException(status_code=401, detail="Edit ID not found!")
+ random_str = os.urandom(8).hex()
+ await redis.set(f"edit_session:{edit_id}:pow", random_str, ex=3800)
+ return GetPowData(data=random_str)
+
+
class UploadImageReturn(BaseModel):
id: str
@router.post("/image", response_model=UploadImageReturn)
-async def upload_image(edit_id: str, file: UploadFile = File()):
+async def upload_image(edit_id: str, pow_data: str, file: UploadFile = File()):
+ print(pow_data)
session_data = await redis.get(f"edit_session:{edit_id}")
+ pow_data_server = await redis.get(f"edit_session:{edit_id}:pow")
+ if pow_data_server is None:
+ print("1")
+ raise HTTPException(status_code=401, detail="Edit ID not found!")
+ if not check_hashcash(pow_data, pow_data_server):
+ print("2")
+ 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!")
+ file_bytes = await file.read()
+ lol = puremagic.magic_string(file_bytes)
+ print(lol)
session_data = EditSessionData.parse_raw(session_data)
file_name = f"{session_data.quiz_id}--{uuid.uuid4()}"
- print("Uploading...")
- await storage.upload(file_name=file_name, file_data=await file.read())
- print("Finished Upload")
- await redis.lpush(f"edit_session:{edit_id}:images", file_name)
+ # await storage.upload(file_name=file_name, file_data=file_bytes)
+ # await redis.lpush(f"edit_session:{edit_id}:images", file_name)
return UploadImageReturn(id=file_name)
diff --git a/classquiz/routers/users.py b/classquiz/routers/users.py
index 9551e18..76cfcef 100644
--- a/classquiz/routers/users.py
+++ b/classquiz/routers/users.py
@@ -53,7 +53,6 @@ async def create_user(user: route_user, background_task: BackgroundTasks) -> Use
raise HTTPException(status_code=400, detail=str(e))
user.verify_key = str(os.urandom(16).hex())
res = await User.objects.filter((User.email == user.email) | (User.username == user.username)).all()
-
if len(res) != 0:
raise HTTPException(status_code=409, detail="User already exists")
@@ -162,9 +161,8 @@ class ForgotPassword(BaseModel):
@router.post("/forgot-password")
async def forgotten_password(forgot_password: ForgotPassword, background_task: BackgroundTasks):
user = await User.objects.filter(email=forgot_password.email, verified=True).get_or_none()
- if user is None:
- raise HTTPException(status_code=404, detail="User not found")
- background_task.add_task(send_forgotten_password_email, email=user.email)
+ if user is not None:
+ background_task.add_task(send_forgotten_password_email, email=user.email)
return {"message": "Password reset email sent"}
diff --git a/classquiz/storage/deta_storage.py b/classquiz/storage/deta_storage.py
index f186117..d86c356 100644
--- a/classquiz/storage/deta_storage.py
+++ b/classquiz/storage/deta_storage.py
@@ -41,6 +41,7 @@ class DetaStorage:
if response.status == 201:
return None
else:
+ print(response.status, await response.json())
raise SavingFailedError
async def delete(self, file_names: [str]) -> None:
diff --git a/frontend/package.json b/frontend/package.json
index 39b12f3..01779b9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -40,6 +40,7 @@
"@uppy/xhr-upload": "^2.1.2",
"autoprefixer": "^10.4.7",
"cookie": "^0.5.0",
+ "crypto-js": "^4.1.1",
"cssnano": "^5.1.11",
"eslint": "^8.17.0",
"eslint-config-prettier": "^8.5.0",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index beda530..1cee9af 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -27,6 +27,7 @@ specifiers:
'@uppy/xhr-upload': ^2.1.2
autoprefixer: ^10.4.7
cookie: ^0.5.0
+ crypto-js: ^4.1.1
cssnano: ^5.1.11
eslint: ^8.17.0
eslint-config-prettier: ^8.5.0
@@ -89,6 +90,7 @@ devDependencies:
'@uppy/xhr-upload': 2.1.2_@uppy+core@2.3.1
autoprefixer: 10.4.7_postcss@8.4.14
cookie: 0.5.0
+ crypto-js: 4.1.1
cssnano: 5.1.11_postcss@8.4.14
eslint: 8.17.0
eslint-config-prettier: 8.5.0_eslint@8.17.0
@@ -1562,6 +1564,13 @@ packages:
which: 2.0.2
dev: true
+ /crypto-js/4.1.1:
+ resolution:
+ {
+ integrity: sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==
+ }
+ dev: true
+
/css-declaration-sorter/6.3.0_postcss@8.4.14:
resolution:
{
diff --git a/frontend/src/lib/editor.svelte b/frontend/src/lib/editor.svelte
index 37b30e2..cdee7ef 100644
--- a/frontend/src/lib/editor.svelte
+++ b/frontend/src/lib/editor.svelte
@@ -1,12 +1,13 @@
@@ -167,7 +175,7 @@
{#if selected_question === -1}
{:else}
-
+
{/if}
diff --git a/frontend/src/lib/editor/card.svelte b/frontend/src/lib/editor/card.svelte
index 3b8abda..e35a8f5 100644
--- a/frontend/src/lib/editor/card.svelte
+++ b/frontend/src/lib/editor/card.svelte
@@ -8,6 +8,7 @@
export let data: EditorData;
export let selected_question: number;
export let edit_id: string;
+ export let pow_data;
const empty_answer: Answer = {
right: false,
answer: ''
@@ -67,6 +68,8 @@
}}
/>
+ {:else if pow_data === undefined}
+
{:else}
{#await import('$lib/editor/uploader.svelte')}
@@ -77,6 +80,7 @@
bind:edit_id
bind:data
bind:selected_question
+ bind:pow_data
/>
{/await}
{/if}
diff --git a/frontend/src/lib/editor/uploader.svelte b/frontend/src/lib/editor/uploader.svelte
index e07f0fc..c663296 100644
--- a/frontend/src/lib/editor/uploader.svelte
+++ b/frontend/src/lib/editor/uploader.svelte
@@ -20,7 +20,9 @@
export let edit_id: string;
export let data: EditorData;
export let selected_question: number;
+ export let pow_data;
+ console.log(pow_data);
const uppy = new Uppy()
.use(DropTarget, {
target: document.body
@@ -34,7 +36,7 @@
quality: 0.6
})
.use(XHRUpload, {
- endpoint: `/api/v1/editor/image?edit_id=${edit_id}`
+ endpoint: `/api/v1/editor/image?edit_id=${edit_id}&pow_data=${pow_data}`
});
const props = { inline: true };
let image_id;
@@ -42,6 +44,7 @@
image_id = response.body.id;
});
uppy.on('complete', (res) => {
+ console.log(pow_data);
data.questions[
selected_question
].image = `${window.location.origin}/api/v1/storage/download/${image_id}`;
diff --git a/frontend/src/lib/hashcash.ts b/frontend/src/lib/hashcash.ts
new file mode 100644
index 0000000..dd23db1
--- /dev/null
+++ b/frontend/src/lib/hashcash.ts
@@ -0,0 +1,47 @@
+import { DateTime } from 'luxon';
+
+const gen_salt = (l: number): string => {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+ const charLength = chars.length;
+ let result = '';
+ for (let i = 0; i < l; i++) {
+ result += chars.charAt(Math.floor(Math.random() * charLength));
+ }
+ return result;
+};
+
+export const mint = async (
+ resource: string,
+ bits = 19,
+ now = null,
+ ext = '',
+ saltchars = 8,
+ stamp_seconds = false
+): Promise => {
+ const ver = '1';
+ let ts;
+ if (stamp_seconds) {
+ ts = DateTime.now().toFormat('yyMMddHHmmss');
+ } else {
+ ts = DateTime.now().toFormat('yyMMdd');
+ }
+ const hex_digits = Math.ceil(bits / 4);
+ const zeros = '0'.repeat(hex_digits);
+ const salt = gen_salt(saltchars);
+ const challenge = `${ver}:${bits}:${ts}:${resource}:${ext}:${salt}`;
+ let digest: string;
+ let counter = 0;
+ let result: string;
+ while (true) {
+ const data = new TextEncoder().encode(`${challenge}:${counter.toString(16)}`);
+ const hashBuffer = await crypto.subtle.digest('SHA-1', data);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ const digest = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
+ if (digest.slice(0, hex_digits) == zeros) {
+ result = counter.toString(16);
+ break;
+ }
+ counter += 1;
+ }
+ return `${challenge}:${result}`;
+};
diff --git a/frontend/src/routes/index.svelte b/frontend/src/routes/index.svelte
index 03f695c..b91e542 100644
--- a/frontend/src/routes/index.svelte
+++ b/frontend/src/routes/index.svelte
@@ -20,6 +20,7 @@
import LandingPromo from '$lib/landing/landing-promo.svelte';
import { onMount } from 'svelte';
+ import { mint } from '$lib/hashcash';
const { t } = getLocalization();