Added NFC-related functions

This commit is contained in:
Ed 2023-07-04 23:07:14 +02:00
parent 3c4d31e1b9
commit 5ca739cec2
5 changed files with 500 additions and 0 deletions

53
money.py Normal file
View File

@ -0,0 +1,53 @@
from sanic import Blueprint, exceptions, response
from time import time
from asyncio import Future
import asyncio
from datetime import datetime
bp = Blueprint("money", url_prefix="/money")
@bp.post("/pos")
async def do_transaction(request):
message = ''
tx_id = request.app.ctx.money.execute('INSERT INTO tx(tag_id, amount, ts) VALUES (?,?,?) RETURNING id', (request.form.get('nfc_id'), request.form.get('total'), time())).fetchone()[0]
for item, qty in request.form.items():
if not item.startswith('itm_'): continue
if qty[0] == '0': continue
request.app.ctx.money.execute('INSERT INTO tx_items(tx_id, item_id, qty) VALUES (?,?,?)', (tx_id, item[4:], qty[0]))
request.app.ctx.money.commit()
return await show_transactions(request, message='Transazione eseguita con successo!')
@bp.get("/pos")
async def show_transactions(request, message=None):
tpl = request.app.ctx.tpl.get_template('pos.html')
items = request.app.ctx.money.execute('SELECT * FROM item')
tx_info = {}
last_tx = request.app.ctx.money.execute('SELECT * FROM tx WHERE amount < 0 ORDER BY ts DESC LIMIT 3').fetchall()
for tx in last_tx:
tx_info[tx['id']] = {'items': request.app.ctx.money.execute('SELECT * FROM tx_items JOIN item ON item_id = item.id AND tx_id = ?', (tx['id'],)).fetchall(), 'order': await request.app.ctx.om.get_order(nfc_id=tx['tag_id']), 'time': datetime.fromtimestamp(tx['ts'] or 0).strftime('%H:%M')}
return response.html(tpl.render(items=items, message=message, last_tx=last_tx, tx_info=tx_info))
@bp.get("/poll_barcode")
async def give_barcode(request):
request.app.ctx.nfc_reads[request.ip] = Future()
try:
bcd = await asyncio.wait_for(request.app.ctx.nfc_reads[request.ip], 20)
except asyncio.TimeoutError:
if not request.ip in request.app.ctx.nfc_reads:
del request.app.ctx.nfc_reads[request.ip]
return response.json({'error': 'no_barcode'})
info = request.app.ctx.money.execute("SELECT count(*), coalesce(sum(amount), 0) FROM tx WHERE coalesce(is_canceled, 0) != 1 AND tag_id = ?", (bcd['id'],)).fetchone()
order = await request.app.ctx.om.get_order(nfc_id=bcd['id'])
desc = ("⚠️" if not bcd['is_secure'] else '') + (f"👤 {order.code} {order.name}" if order else f"🪙 {bcd['id']}") + f" · Transazioni: {info[0]}"
return response.json({**bcd, 'txamt': info[0], 'balance': info[1], 'desc': desc})

72
nfc.py Normal file
View File

@ -0,0 +1,72 @@
from sanic import Blueprint, exceptions, response
from random import choice
from ext import *
from config import headers, PROPIC_DEADLINE
from PIL import Image
from os.path import isfile
from os import unlink
from io import BytesIO
from hashlib import sha224
from time import time
from urllib.parse import unquote
from base64 import b16decode
from asyncio import Future
import json
import re
import asyncio
bp = Blueprint("nfc")
@bp.post("/nfc/read")
async def handle_reading(request):
payload = request.json
if payload['count']:
request.app.ctx.nfc_counts.execute('INSERT INTO read_count(tag_id, ts, count) VALUES (?,?,?)', (payload['id'], int(time()), payload['count']))
request.app.ctx.nfc_counts.commit()
if payload['boopbox_id']:
await request.app.ctx.boopbox_queue.put(payload)
return response.text('ok')
if not request.ip in request.app.ctx.nfc_reads:
return response.text('wasted read')
try:
request.app.ctx.nfc_reads[request.ip].set_result(payload)
except asyncio.exceptions.InvalidStateError:
del request.app.ctx.nfc_reads[request.ip]
return response.text('ok')
@bp.get("/fz23/<tag_id:([bc][0-9A-F]{14}x[0-9A-F]{6})>")
async def handle_nfc_tag(request, tag_id):
tag = re.match('([bc])([0-9A-F]{14})x([0-9A-F]{6})', tag_id)
# Store the read count
read_count = int.from_bytes(b16decode(tag.group(3)))
request.app.ctx.nfc_counts.execute('INSERT INTO read_count(tag_id, ts, count, is_web) VALUES (?,?,?,1)', (tag.group(2), int(time()), read_count))
request.app.ctx.nfc_counts.commit()
# If it's a coin, just show the coin template
if tag.group(1) == 'c':
return response.redirect(f"coin/{tag.group(2)}")
# If it's a badge, look for the corresponding order
for o in request.app.ctx.om.cache.values():
if o.nfc_id == tag.group(2):
return response.redirect(o.code)
raise exceptions.NotFound("Unknown tag :(")
@bp.get("/fz23/coin/<tag_id>")
async def show_coin(request, tag_id):
balance = request.app.ctx.money.execute('SELECT sum(amount) FROM tx WHERE tag_id = ?', (tag_id,)).fetchone()[0]
tpl = request.app.ctx.tpl.get_template('coin.html')
return response.html(tpl.render(balance=balance))
@bp.get("/fz23/<code:[A-Z0-9]{5}>")
async def show_order(request, code):
return response.html(f"<h1>Badge</h1><p>{code}</p>")

37
tpl/badge.html Normal file
View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<head>
<meta charset="utf8" />
<title>{{order.name}}</title>
<meta name="viewport" content="width=380rem" />
<style>
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Lato&display=swap');
* {border:0;margin:0;padding:0;}
body{background:#000;color:#ddd;font-family:'Lato';font-size:1.1rem;}
h1 {font-size: 3.5em;text-transform:uppercase}
#center {margin:1em auto;padding:0 1rem;max-width:30rem;text-align:center;}
nav > a {font-size:1.1em;display:block;background-color:#336;color:#fff;text-decoration:none;padding:0.5em;border-radius:3em;text-align:center;margin:1em;font-weight:bold;}
a > img {display:inline;height:1em;line-height:1em;vertical-align:middle;}
p {margin:0.6em 0;}
hr {border:0;height:12px;background:url('wiggly.png');background-repeat:no-repeat;background-position:center;margin:1.5em 0;}
a {color:#f0f;}
.bebas {font-family:'Bebas Neue';font-weight:900;background: linear-gradient(to right, #f32170, #ff6b08, #cf23cf, #eedd44);
-webkit-text-fill-color: transparent;-webkit-background-clip: text;transform:.3s all;}
</style>
</head>
<body>
<div id="center">
<img src="https://reg.furizon.net/res/propic/{{order.propic or 'default.png'}}" style="width:100%;max-height:10em;max-width:10em;"/>
<hr />
<h2 class="bebas" style="font-size:4em;font-family:'Bebas Neue';">{{order.name}}</h2>
<p>Is an attendee of Furizon 2023 :D<br/><br />
<hr />
<nav>
<a style="background:#1086D7;" href="https://t.me/{{order.telegram_username}}">Telegram</a>
</nav>
<h3 class="bebas">Stay cool~<br/>Stay Furizon</h3>
</div>
</body>
</html>

44
tpl/coin.html Normal file
View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>You found a coin!</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
@import url(https://fonts.bunny.net/css?family=arbutus:400);
body {font-family:'arbutus', 'Comic Sans MS', sans-serif;background:url('/res/wood.jpg') #222 no-repeat;background-size:cover;color:#eee;text-align:center;}
strong {color:#de0;}
main {display:block;max-width:26em;margin:1em auto;}
video {display:block;width:100%;}
</style>
</head>
<body>
<main>
<video autoplay muted oncanplay="preloadSecondVideo()" onended="loopVideo()">
<source src="/res/video/coin-intro.webm" type="video/webm">
<img src="/res/video/coin.webp" alt="Fallback Image">
</video>
{% if balance < 0 %}
<h1>You found a<br/><strong>{{balance}}FZ</strong><br />coin!</h1>
{% else %}
<h1>You found a coin!<br />(but sadly, it's already spent)</h1>
{% endif %}
</main>
<script>
function preloadSecondVideo() {
var secondVideo = new Image(); // Create an image element to preload the second video
secondVideo.src = '/res/video/coin-loop.webm';
}
function loopVideo() {
var video = document.querySelector('video');
video.src = '/res/video/coin-loop.webm';
video.loop = true;
video.load();
video.play();
}
</script>
</body>
</html>

294
tpl/pos.html Normal file
View File

@ -0,0 +1,294 @@
<!DOCTYPE html>
<html>
<head>
<title>Fz vending</title>
<style>
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
25%, 75% {
transform: translateX(-10px);
}
50% {
transform: translateX(10px);
}
}
body {margin:0;padding:0;background:#222;color:#eee;font-family:"Open Sans", monospace;}
header {padding-left:0.5em;height:4rem;line-height:4rem;background:#111;font-size:2.2rem;color:#fa0;}
header img {height:3.6rem;margin-right:0.3em;display:inline-block;vertical-align:middle;}
header strong {font-size:1.05em;color:#f00;vertical-align:baseline;}
aside {position:fixed;height:100%;overflow:auto;top:0;width:18em;right:0;background:#111;}
main {padding-right:18em;}
.shake {animation: shake 0.3s 1;}
#toolkit {position:fixed;bottom:0;right:0;width:18em;padding:0.5em;box-sizing:border-box;background:#000;text-align:center;}
#toolkit img {display:inline;height:3rem;margin:0.5rem 0.1rem;border:1px dashed rgba(255,255,255,0.2);padding:0.2em;}
h2 {margin-top:0;font-size: 2.2rem;line-height:4rem;display:block;text-align:center;background:#070;color:#eee;}
h3 {margin-top:1em;font-size: 1.4rem;display:block;text-align:center;padding:0.3em 0em;background:#fa0;color:#000;}
button {background: #fa0;padding:0.2em;margin:0.1em;font-size:1.2em;width:1.6em;border-radius:6px;border:none;transition:all 0.1s;}
button:active {transform: scale(0.95);}
aside strong {font-size:1.9em;color:#f00;vertical-align:baseline;}
aside button {font-size:3em;}
ul {padding:0.5em 1em;}
li {line-height:1.8em;}
p {margin-bottom: 1em;}
em {font-size: 1.3em;font-weight:bold;}
sup {font-size: 0.7em;color:#fc0;}
table {width:100%;font-size:1.3em;}
table td {padding:0.1em;}
table td:first-child {color:#0f0;text-align:right;}
#mask {position:fixed;height:100%;width:100%;top:0;left:0;background:rgba(0,0,0,0.3);z-index:9999;backdrop-filter: blur(20px);text-align:center;}
#mask h1 {font-size:4em;margin:3em 0 0.6em;}
#mask button {font-size:3em;background:#ddd;}
.secret {display:none;}
input {color:#eee;background:#000;font-size:2em;width:2em;font-size:center;border:none;text-align:center;}
</style>
</head>
<body>
<header id="nfcInfo">{{message or 'Scannerizza un NFC...'}}</header>
<form method="post" action="pos">
<input type="hidden" id="totalValue" name="total" />
<input type="hidden" id="nfcid" name="nfc_id" value="" />
<main>
<table>
{% for item in items %}
<tr {% if item.price > 0 %}class="secret"{% endif %}>
<td>{{item.name}}</td>
<td><strong>{{item.price}}FZ</strong></td>
<td>
<button type="button" onclick="decrementValue(this)">-</button>
<input type="text" name="itm_{{item.id}}" value="0" data-price="{{item.price}}" autocomplete="off" oninput="updateTotal();" />
<button type="button" onclick="incrementValue(this)">+</button>
</td>
</tr>
{% endfor %}
</table>
</main>
<aside>
<h2><span id="badgeBalance">0</span> FZ</h2>
<p style="text-align:center;">Totale <strong><span id="totalValueShow">0</span>FZ</strong></p>
<div style="text-align:center;"><button style="background:#090;" id="sendButton" disabled="disabled"></button><button type="button" onClick="zeroItems();" style="background:#c00;"></button></div>
<h3 id="triggerElement">Ultime transazioni</h3>
{% for tx in last_tx %}
<ul>
<li><em>{{tx_info[tx['id']]['order'].name or tx['tag_id']}}<sup>{{tx_info[tx['id']]['time']}}</sup></em></li>
{% for tx_item in tx_info[tx['id']]['items'] %}
<li><strong>{{tx_item['qty']}}x</strong> {{tx_item['name']}}</li>
{% endfor %}
<li><em>Totale: {{tx['amount']}}FZ</em></li>
</ul>
{% endfor %}
<span style="display:none;" id="debug"></span>
</aside>
<div id="toolkit">
<a href="pos"><img src="/res/icons/refresh.svg" style="filter: invert(1);"/></a>
<a href="#"><img src="/res/icons/qr.svg" id="executeButton" style="filter: invert(1);"/></a>
</div>
</form>
</body>
<script>
let isClosing = false;
window.addEventListener('beforeunload', handleBeforeUnload);
function handleBeforeUnload(event) {
isClosing = true;
}
const triggerElement = document.getElementById('triggerElement');
const secretElements = document.querySelectorAll('.secret');
let secretElementsVisible = localStorage.getItem('secretElementsVisible');
let pressStart;
let isPress = false;
triggerElement.addEventListener('mousedown', handleMouseDown);
triggerElement.addEventListener('touchstart', handleMouseDown);
triggerElement.addEventListener('mouseup', handleMouseUp);
triggerElement.addEventListener('touchend', handleMouseUp);
function handleMouseDown() {
pressStart = Date.now();
isPress = true;
setTimeout(toggleSecretElements, 1200); // Delay the toggle action by 500 milliseconds
}
function handleMouseUp() {
isPress = false;
}
if (secretElementsVisible === 'true') {
secretElements.forEach(element => {
element.classList.remove('secret');
});
}
const executeButton = document.getElementById('executeButton');
executeButton.addEventListener('click', () => {
const code = prompt('Enter the code:');
if (code && /^([A-F0-9]{2}){4,}$/.test(code)) {
const payload = {
id: code,
is_secure: false,
count: null,
boopbox_id: null
};
fetch('/nfc/read', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(data => {
// Handle the response data as needed
console.log(data);
})
.catch(error => {
// Handle any errors that occur during the POST request
console.error(error);
});
} else if (code !== null) {
alert('Invalid code format. Please enter a valid code.');
}
});
function toggleSecretElements() {
const pressDuration = Date.now() - pressStart;
if (pressDuration >= 1000 && isPress) {
secretElementsVisible = !secretElementsVisible;
if (secretElementsVisible) {
localStorage.setItem('secretElementsVisible', 'true');
secretElements.forEach(element => {
element.classList.remove('secret');
});
} else {
localStorage.removeItem('secretElementsVisible');
secretElements.forEach(element => {
element.classList.add('secret');
});
}
}
}
// Function to perform long-polling
function longPoll() {
fetch('poll_barcode')
.then((response) => response.json())
.then((data) => {
// Update the table with received JSON data
if(!data.error) {
document.getElementById('nfcInfo').classList.remove('shake');
document.getElementById('nfcInfo').innerHTML = data.desc;
void document.getElementById('nfcInfo').offsetWidth;
document.getElementById('nfcInfo').classList.add('shake');
document.getElementById('nfcid').value = data.id;
document.getElementById('badgeBalance').innerHTML = data.balance;
document.getElementById('debug').innerHTML = JSON.stringify(data);
} else {
console.log("Got no barcode.")
}
// Start the next long-poll request
updateTotal();
longPoll();
})
.catch((error) => {
if(isClosing) return;
document.getElementById('nfcInfo').innerHTML = error;
// Retry long-polling after a delay in case of errors
setTimeout(longPoll, 1000);
});
}
// Start long-polling
longPoll();
function incrementValue(button) {
var input = button.previousElementSibling;
var value = parseInt(input.value) || 0;
input.value = Math.max(value + 1, 0);
updateTotal();
}
function decrementValue(button) {
var input = button.nextElementSibling;
var value = parseInt(input.value) || 0;
input.value = Math.max(value - 1, 0);
updateTotal();
}
function zeroItems() {
var inputs = document.querySelectorAll('input[name^="itm_"]');
inputs.forEach(function(input) {
input.value = 0;
});
updateTotal();
}
function updateTotal() {
var inputs = document.querySelectorAll('input[name^="itm_"]');
var total = 0;
inputs.forEach(function(input) {
var value = parseInt(input.value) || 0;
var price = parseFloat(input.getAttribute('data-price'));
total += value * price;
});
document.getElementById('totalValue').value = total;
document.getElementById('totalValueShow').innerHTML = total;
let badgeBalance = Number(document.getElementById('badgeBalance').innerHTML);
if(badgeBalance+total < 0 || total == 0) {
document.getElementById('sendButton').setAttribute("disabled", "disabled");
} else {
document.getElementById('sendButton').removeAttribute("disabled");
}
}
</script>
</html>