🚧 Added Proof of Work on image-upload and more

This commit is contained in:
Mawoka
2022-06-20 21:47:12 +02:00
parent 2b8168ad5e
commit 25ac42bdae
13 changed files with 297 additions and 23 deletions
+1
View File
@@ -30,6 +30,7 @@ pillow = ">=9.1.1"
authlib = "*"
httpx = "*"
itsdangerous = "*"
puremagic = "*"
[dev-packages]
coverage = "*"
Generated
+16 -8
View File
@@ -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": {
+167
View File
@@ -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)
+32 -6
View File
@@ -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)
+2 -4
View File
@@ -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"}
+1
View File
@@ -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:
+1
View File
@@ -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",
+9
View File
@@ -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:
{
+12 -4
View File
@@ -1,12 +1,13 @@
<script lang="ts">
import { getLocalization } from '$lib/i18n';
import * as yup from 'yup';
import { mint } from '$lib/hashcash';
import { dataSchema } from '$lib/yupSchemas';
import type { EditorData, Question, Answer } from './quiz_types';
import Sidebar from '$lib/editor/sidebar.svelte';
import SettingsCard from '$lib/editor/settings-card.svelte';
import QuizCard from '$lib/editor/card.svelte';
import Spinner from './Spinner.svelte';
import { onMount } from 'svelte';
const { t } = getLocalization();
@@ -67,6 +68,7 @@
};
let edit_id;
let confirm_to_leave = true;
let pow_data;
const getEditID = async () => {
let res;
@@ -82,7 +84,7 @@
if (res.status === 200) {
const json = await res.json();
edit_id = json.token;
console.log(edit_id, json);
setPOWdata();
} else {
alert('Error!');
}
@@ -110,7 +112,6 @@
body: JSON.stringify(data)
});
if (res.ok) {
console.log('Hier');
confirm_to_leave = false;
console.log(confirm_to_leave);
window.location.href = '/overview';
@@ -118,6 +119,13 @@
alert('Error');
}
};
const setPOWdata = async () => {
const res = await fetch(`/api/v1/editor/pow?edit_id=${edit_id}`);
const data = (await res.json()).data;
console.log(data);
pow_data = await mint(data);
console.log(pow_data);
};
</script>
<svelte:window on:beforeunload={confirmUnload} />
@@ -167,7 +175,7 @@
{#if selected_question === -1}
<SettingsCard bind:data />
{:else}
<QuizCard bind:data bind:selected_question bind:edit_id />
<QuizCard bind:data bind:selected_question bind:edit_id bind:pow_data />
{/if}
</div>
</div>
+4
View File
@@ -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 @@
}}
/>
</div>
{:else if pow_data === undefined}
<Spinner />
{:else}
{#await import('$lib/editor/uploader.svelte')}
<Spinner />
@@ -77,6 +80,7 @@
bind:edit_id
bind:data
bind:selected_question
bind:pow_data
/>
{/await}
{/if}
+4 -1
View File
@@ -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}`;
+47
View File
@@ -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<string> => {
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}`;
};
+1
View File
@@ -20,6 +20,7 @@
import LandingPromo from '$lib/landing/landing-promo.svelte';
import { onMount } from 'svelte';
import { mint } from '$lib/hashcash';
const { t } = getLocalization();