Further bottle porting

This commit is contained in:
s3lph 2020-02-03 20:44:58 +01:00
parent 8e8f159150
commit e5c3fad812
18 changed files with 66 additions and 387 deletions

2
doc

@ -1 +1 @@
Subproject commit 0fcf4244f90d93cbc7be8e670aeaa30288d24ae3 Subproject commit 8d6d6b6fece9d0b2dedc11ad41d4b96554a8ea36

View file

@ -3,6 +3,8 @@ from typing import Callable
from datetime import timedelta from datetime import timedelta
from threading import Event, Timer, Thread from threading import Event, Timer, Thread
from matemat.webserver import Logger
_CRON_STATIC_EVENT: Event = Event() _CRON_STATIC_EVENT: Event = Event()
@ -67,6 +69,7 @@ def cron(weeks: int = 0,
# This function is called once in the specified interval # This function is called once in the specified interval
def cron(): def cron():
logger = Logger.instance()
# Reschedule the job # Reschedule the job
t: Timer = _GlobalEventTimer(delta.total_seconds(), _CRON_STATIC_EVENT, cron) t: Timer = _GlobalEventTimer(delta.total_seconds(), _CRON_STATIC_EVENT, cron)
t.start() t.start()

View file

@ -247,7 +247,7 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
elif change == 'defaultimg': elif change == 'defaultimg':
# Iterate the possible images to set # Iterate the possible images to set
for category in 'users', 'products': for category in 'users', 'products':
if category not in args: if category not in files:
continue continue
# Read the raw image data from the request # Read the raw image data from the request
default: bytes = files[category].file.read() default: bytes = files[category].file.read()

View file

@ -18,7 +18,7 @@ def start() -> str:
""" """
Start a new session, or resume the session identified by the session cookie sent in the HTTP request. Start a new session, or resume the session identified by the session cookie sent in the HTTP request.
:return: A tuple consisting of the session ID (a UUID string), and the session timeout date. :return: The session ID.
""" """
# Reference date for session timeout # Reference date for session timeout
now = datetime.utcnow() now = datetime.utcnow()

View file

@ -1,103 +1,18 @@
import threading
from http.client import HTTPResponse
from typing import Any, Callable, Dict, Tuple, Union from typing import Any, Callable, Dict, Tuple, Union
import unittest.mock import unittest.mock
from io import BytesIO import urllib.request
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from abc import ABC from abc import ABC
from datetime import datetime from datetime import datetime
from http.server import HTTPServer
import logging import logging
import jinja2 import jinja2
from matemat.webserver import pagelet, RequestArguments import bottle
class HttpResponse:
"""
A really basic HTTP response container and parser class, just good enough for unit testing a HTTP server, if even.
DO NOT USE THIS OUTSIDE UNIT TESTING!
Usage:
response = HttpResponse()
while response.parse_phase != 'done'
response.parse(<read from somewhere>)
print(response.statuscode)
"""
def __init__(self) -> None:
# The HTTP status code of the response
self.statuscode: int = 0
# HTTP headers set in the response
self.headers: Dict[str, str] = {
'Content-Length': 0
}
self.pagelet: str = None
# The response body
self.body: bytes = bytes()
# Parsing phase, one of 'begin', 'hdr', 'body' or 'done'
self.parse_phase = 'begin'
# Buffer for uncompleted lines
self.buffer: bytes = bytes()
def __finalize(self):
self.parse_phase = 'done'
self.pagelet = self.headers.get('X-Test-Pagelet', None)
def parse(self, fragment: bytes) -> None:
"""
Parse a new fragment of data. This function does nothing if the parsed HTTP response is already complete.
DO NOT USE THIS OUTSIDE UNIT TESTING!
:param fragment: The data fragment to parse.
"""
# response packet complete, nothing to do
if self.parse_phase == 'done':
return
# If in the body phase, simply decode and append to the body, while the body is not complete yet
elif self.parse_phase == 'body':
self.body += fragment
if len(self.body) >= int(self.headers['Content-Length']):
self.__finalize()
return
if b'\r\n' not in fragment:
# If the fragment does not contain a CR-LF, add it to the buffer, we only want to parse whole lines
self.buffer = self.buffer + fragment
else:
if not fragment.endswith(b'\r\n'):
# Special treatment for no trailing CR-LF: Add remainder to buffer
head, tail = fragment.rsplit(b'\r\n', 1)
data: bytes = (self.buffer + head)
self.buffer = tail
else:
data: bytes = (self.buffer + fragment)
self.buffer = bytes()
# Iterate the lines that are ready to be parsed
for line in data.split(b'\r\n'):
# The 'begin' phase indicates that the parser is waiting for the HTTP status line
if self.parse_phase == 'begin':
if line.startswith(b'HTTP/'):
# Parse the statuscode and advance to header parsing
_, statuscode, _ = line.decode('utf-8').split(' ', 2)
self.statuscode = int(statuscode)
self.parse_phase = 'hdr'
elif self.parse_phase == 'hdr':
# Parse a header line and add it to the header dict
if len(line) > 0:
k, v = line.decode('utf-8').split(':', 1)
self.headers[k.strip()] = v.strip()
else:
# Empty line separates header from body
self.parse_phase = 'body'
elif self.parse_phase == 'body':
# if there is a remainder in the data packet, it is (part of) the body, add to body string
self.body += line
if len(self.body) >= int(self.headers['Content-Length']):
self.__finalize()
class MockServer: class MockServer:
@ -129,76 +44,7 @@ class MockServer:
# sh: logging.StreamHandler = logging.StreamHandler() # sh: logging.StreamHandler = logging.StreamHandler()
# sh.setFormatter(logging.Formatter('%(asctime)s %(name)s [%(levelname)s]: %(message)s')) # sh.setFormatter(logging.Formatter('%(asctime)s %(name)s [%(levelname)s]: %(message)s'))
# self.logger.addHandler(sh) # self.logger.addHandler(sh)
bottle.run(host='::1', port='8888')
class MockSocket(bytes):
"""
A mock implementation of a socket.socket for http.server.BaseHTTPRequestHandler.
The bytes inheritance is due to a broken type annotation in BaseHTTPRequestHandler.
"""
def __init__(self) -> None:
super().__init__()
# The request string
self.__request = bytes()
# The parsed response
self.__packet = HttpResponse()
def set_request(self, request: bytes) -> None:
"""
Sets the HTTP request to send to the server.
:param request: The request
"""
self.__request: bytes = request
def makefile(self, mode: str, size: int) -> BytesIO:
"""
Required by http.server.HTTPServer.
:return: A dummy buffer IO object instead of a network socket file handle.
"""
return BytesIO(self.__request)
def sendall(self, b: bytes) -> None:
"""
Required by http.server.HTTPServer.
:param b: The data to send to the client. Will be parsed directly instead.
"""
self.__packet.parse(b)
def get_response(self) -> HttpResponse:
"""
Fetches the parsed HTTP response generated by the server.
:return: The response object.
"""
return self.__packet
def test_pagelet(path: str):
def with_testing_headers(fun: Callable[[str,
str,
RequestArguments,
Dict[str, Any],
Dict[str, str],
Dict[str, str]],
Union[bytes, str, Tuple[int, str]]]):
@pagelet(path)
def testing_wrapper(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]):
headers['X-Test-Pagelet'] = fun.__name__
result = fun(method, path, args, session_vars, headers, pagelet_variables)
return result
return testing_wrapper
return with_testing_headers
class AbstractHttpdTest(ABC, unittest.TestCase): class AbstractHttpdTest(ABC, unittest.TestCase):
@ -215,8 +61,13 @@ class AbstractHttpdTest(ABC, unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.tempdir: TemporaryDirectory = TemporaryDirectory(prefix='matemat.', dir='/tmp/') self.tempdir: TemporaryDirectory = TemporaryDirectory(prefix='matemat.', dir='/tmp/')
self.server: HTTPServer = MockServer(webroot=self.tempdir.name) self.server = threading.Thread(target=bottle.run, kwargs={'host': '::1', 'port': 8888, 'debug': True})
self.client_sock: MockSocket = MockSocket() self.server.start()
def tearDown(self): def tearDown(self):
self.server._stop()
self.tempdir.cleanup() self.tempdir.cleanup()
@staticmethod
def request(query) -> HTTPResponse:
return urllib.request.urlopen(f'http://[::1]:8888{query}')

View file

@ -8,7 +8,7 @@ from io import StringIO
import logging import logging
import sys import sys
from matemat.webserver import parse_config_file from matemat.webserver.config import parse_config_file
_EMPTY_CONFIG = '' _EMPTY_CONFIG = ''

View file

@ -1,108 +0,0 @@
from typing import Any, Dict, Union
from matemat.exceptions import HttpException
from matemat.webserver import HttpHandler, RequestArguments, PageletResponse
from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest, test_pagelet
@test_pagelet('/just/testing/http_exception')
def test_pagelet_http_exception(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
raise HttpException(int(str(args.exc)), 'Test Exception')
@test_pagelet('/just/testing/value_error')
def test_pagelet_value_error(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
raise ValueError('test')
@test_pagelet('/just/testing/permission_error')
def test_pagelet_permission_error(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
raise PermissionError('test')
@test_pagelet('/just/testing/other_error')
def test_pagelet_other_error(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]) -> Union[bytes, str, PageletResponse]:
raise TypeError('test')
class TestHttpd(AbstractHttpdTest):
def test_httpd_get_illegal_path(self):
self.client_sock.set_request(b'GET /foo?bar?baz HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(400, packet.statuscode)
def test_httpd_post_illegal_path(self):
self.client_sock.set_request(b'POST /foo?bar?baz HTTP/1.1\r\n'
b'Content-Length: 0\r\n'
b'Content-Type: application/x-www-form-urlencoded\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(400, packet.statuscode)
def test_httpd_post_illegal_header(self):
self.client_sock.set_request(b'POST /foo?bar=baz HTTP/1.1\r\n'
b'Content-Length: 0\r\n'
b'Content-Type: application/octet-stream\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(400, packet.statuscode)
def test_httpd_post_request_too_big(self):
self.client_sock.set_request(b'POST /foo?bar=baz HTTP/1.1\r\n'
b'Content-Length: 1000001\r\n'
b'Content-Type: application/octet-stream\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(413, packet.statuscode)
def test_httpd_exception_http_400(self):
self.client_sock.set_request(b'GET /just/testing/http_exception?exc=400 HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(400, packet.statuscode)
def test_httpd_exception_http_500(self):
self.client_sock.set_request(b'GET /just/testing/http_exception?exc=500 HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(500, packet.statuscode)
def test_httpd_exception_value_error(self):
self.client_sock.set_request(b'GET /just/testing/value_error HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(400, packet.statuscode)
def test_httpd_exception_permission_error(self):
self.client_sock.set_request(b'GET /just/testing/permission_error HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(403, packet.statuscode)
def test_httpd_exception_other_error(self):
self.client_sock.set_request(b'GET /just/testing/other_error HTTP/1.1\r\n\r\n')
HttpHandler(self.client_sock, ('::1', 45678), self.server)
packet = self.client_sock.get_response()
self.assertEqual(500, packet.statuscode)

View file

@ -1,68 +0,0 @@
from typing import Any, Dict
import unittest
import http.client
import logging
import threading
from matemat.webserver import MatematWebserver, RequestArguments, pagelet_init, pagelet
@pagelet('/just/testing/init')
def init_test_pagelet(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
pagelet_variables: Dict[str, str]):
return pagelet_variables['Unit-Test']
_INIT_FAIL = False
@pagelet_init
def init(config: Dict[str, str],
logger: logging.Logger):
if _INIT_FAIL:
raise ValueError('This error should be raised!')
config['Unit-Test'] = 'Pagelet Init Test'
class TestPageletInitialization(unittest.TestCase):
def setUp(self):
self.srv = MatematWebserver('::1', 0, '/nonexistent', '/nonexistent', {}, {},
logging.NOTSET, logging.NullHandler())
self.srv_port = int(self.srv._httpd.socket.getsockname()[1])
self.timer = threading.Timer(5.0, self.srv._httpd.shutdown)
self.timer.start()
def tearDown(self):
self.timer.cancel()
if self.srv is not None:
self.srv._httpd.socket.close()
global _INIT_FAIL
_INIT_FAIL = False
def test_pagelet_init_ok(self):
"""
Test successful pagelet initialization
"""
thread = threading.Thread(target=self.srv.start)
thread.start()
con = http.client.HTTPConnection(f'[::1]:{self.srv_port}')
con.request('GET', '/just/testing/init')
response = con.getresponse().read()
self.srv._httpd.shutdown()
self.assertEqual(b'Pagelet Init Test', response)
def test_pagelet_init_fail(self):
"""
Test unsuccessful pagelet initialization
"""
global _INIT_FAIL
_INIT_FAIL = True
with self.assertRaises(ValueError):
self.srv.start()

View file

@ -1,26 +1,22 @@
from typing import Any, Dict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from time import sleep from time import sleep
from matemat.webserver import HttpHandler, RequestArguments from bottle import route, run
from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest, test_pagelet
from matemat.webserver import session
from matemat.webserver.test.abstract_httpd_test import AbstractHttpdTest
@test_pagelet('/just/testing/sessions') @route('/just/testing/sessions')
def session_test_pagelet(method: str, def session_test_pagelet():
path: str, s = session.start()
args: RequestArguments, session.put(s, 'test', 'hello, world!')
session_vars: Dict[str, Any], return f'{s}'
headers: Dict[str, str],
pagelet_variables: Dict[str, str]):
session_vars['test'] = 'hello, world!'
headers['Content-Type'] = 'text/plain'
return 'session test'
class TestSession(AbstractHttpdTest): class TestSession(AbstractHttpdTest):
""" """
Test session handling of the Matemat webserver. Test session handling of the Matemat webserver.
""" """
@ -29,18 +25,12 @@ class TestSession(AbstractHttpdTest):
# Reference date to make sure the session expiry lies in the future # Reference date to make sure the session expiry lies in the future
refdate: datetime = datetime.utcnow() + timedelta(seconds=3500) refdate: datetime = datetime.utcnow() + timedelta(seconds=3500)
# Send a mock GET request for '/just/testing/sessions' # Send a mock GET request for '/just/testing/sessions'
self.client_sock.set_request(b'GET /just/testing/sessions HTTP/1.1\r\n\r\n')
# Trigger request handling
handler = HttpHandler(self.client_sock, ('::1', 45678), self.server)
# Fetch the parsed response
packet = self.client_sock.get_response()
# Make sure a full HTTP response was parsed
self.assertEqual('done', packet.parse_phase)
# Make sure the request was served by the test pagelet
self.assertEqual('session_test_pagelet', packet.pagelet)
self.assertEqual(200, packet.statuscode)
session_id: str = list(handler.server.session_vars.keys())[0] packet = self.request('/just/testing/sessions')
# Make sure the request was served by the test pagelet
self.assertEqual(200, packet.code)
session_id: str = packet.read().decode()
# Make sure a cookie was set - assuming that only one was set # Make sure a cookie was set - assuming that only one was set
self.assertIn('Set-Cookie', packet.headers) self.assertIn('Set-Cookie', packet.headers)
# Split into the cookie itself # Split into the cookie itself
@ -54,9 +44,6 @@ class TestSession(AbstractHttpdTest):
_, expdatestr = expiry.split('=', 1) _, expdatestr = expiry.split('=', 1)
expdate = datetime.strptime(expdatestr, '%a, %d %b %Y %H:%M:%S GMT') expdate = datetime.strptime(expdatestr, '%a, %d %b %Y %H:%M:%S GMT')
self.assertTrue(expdate > refdate) self.assertTrue(expdate > refdate)
# Make sure the session exists on the server
self.assertIn('test', handler.session_vars)
self.assertEqual('hello, world!', handler.session_vars['test'])
def test_resume_session(self): def test_resume_session(self):
# Test session expiry date # Test session expiry date

View file

@ -1,7 +1,7 @@
.thumblist-item { .thumblist-item {
display: inline-block; display: inline-block;
margin: 10px; margin: 5px;
padding: 10px; padding: 5px;
background: #f0f0f0; background: #f0f0f0;
text-decoration: none; text-decoration: none;
} }
@ -11,14 +11,14 @@
} }
.thumblist-item .imgcontainer { .thumblist-item .imgcontainer {
width: 150px; width: 100px;
height: 150px; height: 100px;
position: relative; position: relative;
} }
.thumblist-item .imgcontainer img { .thumblist-item .imgcontainer img {
max-width: 150px; max-width: 100px;
max-height: 150px; max-height: 100px;
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
@ -30,6 +30,15 @@
font-weight: bolder; font-weight: bolder;
} }
.thumblist-stock {
position: absolute;
z-index: 10;
bottom: 0;
right: 0;
background: #f0f0f0;
padding: 10px;
}
@media print { @media print {
footer { footer {
position: fixed; position: fixed;

View file

@ -31,7 +31,7 @@
<h2>Avatar</h2> <h2>Avatar</h2>
<form id="admin-avatar-form" method="post" action="/admin?change=avatar" enctype="multipart/form-data" accept-charset="UTF-8"> <form id="admin-avatar-form" method="post" action="/admin?change=avatar" enctype="multipart/form-data" accept-charset="UTF-8">
<img src="/upload/thumbnails/users/{{ authuser.id }}.png" alt="Avatar of {{ authuser.name }}" /><br/> <img src="/static/upload/thumbnails/users/{{ authuser.id }}.png" alt="Avatar of {{ authuser.name }}" /><br/>
<label for="admin-avatar-avatar">Upload new file: </label> <label for="admin-avatar-avatar">Upload new file: </label>
<input id="admin-avatar-avatar" type="file" name="avatar" accept="image/*" /><br/> <input id="admin-avatar-avatar" type="file" name="avatar" accept="image/*" /><br/>

View file

@ -94,12 +94,12 @@
<form id="admin-default-images-form" method="post" action="/admin?adminchange=defaultimg" enctype="multipart/form-data" accept-charset="UTF-8"> <form id="admin-default-images-form" method="post" action="/admin?adminchange=defaultimg" enctype="multipart/form-data" accept-charset="UTF-8">
<label for="admin-default-images-user"> <label for="admin-default-images-user">
<img src="/upload/thumbnails/users/default.png" alt="Default user avatar" /> <img src="/static/upload/thumbnails/users/default.png" alt="Default user avatar" />
</label><br/> </label><br/>
<input id="admin-default-images-user" type="file" name="users" accept="image/*" /><br/> <input id="admin-default-images-user" type="file" name="users" accept="image/*" /><br/>
<label for="admin-default-images-product"> <label for="admin-default-images-product">
<img src="/upload/thumbnails/products/default.png" alt="Default product avatar" /> <img src="/static/upload/thumbnails/products/default.png" alt="Default product avatar" />
</label><br/> </label><br/>
<input id="admin-default-images-product" type="file" name="products" accept="image/*" /><br/> <input id="admin-default-images-product" type="file" name="products" accept="image/*" /><br/>

View file

@ -43,7 +43,6 @@
<ul> <ul>
<li> {{ setupname|safe }} <li> {{ setupname|safe }}
<li> Matemat {{ __version__ }} <li> Matemat {{ __version__ }}
<li> &copy; 2018 s3lph
<li> MIT License <li> MIT License
{# This used to be a link to the GitLab repo. However, users of the testing environment always clicked {# This used to be a link to the GitLab repo. However, users of the testing environment always clicked
that link and couldn't come back, because the UI was running in touch-only kiosk mode. #} that link and couldn't come back, because the UI was running in touch-only kiosk mode. #}

View file

@ -24,7 +24,7 @@
<input id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/> <input id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/>
<label for="modproduct-image"> <label for="modproduct-image">
<img height="150" src="/upload/thumbnails/products/{{ product.id }}.png" alt="Image of {{ product.name }}" /> <img height="150" src="/static/upload/thumbnails/products/{{ product.id }}.png" alt="Image of {{ product.name }}" />
</label><br/> </label><br/>
<input id="modproduct-image" type="file" name="image" accept="image/*" /><br/> <input id="modproduct-image" type="file" name="image" accept="image/*" /><br/>

View file

@ -42,7 +42,7 @@
<input id="moduser-account-balance-reason" type="text" name="reason" placeholder="Shows up on receipt" /><br/> <input id="moduser-account-balance-reason" type="text" name="reason" placeholder="Shows up on receipt" /><br/>
<label for="moduser-account-avatar"> <label for="moduser-account-avatar">
<img src="/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}" /> <img src="/static/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}" />
</label><br/> </label><br/>
<input id="moduser-account-avatar" type="file" name="avatar" accept="image/*" /><br/> <input id="moduser-account-avatar" type="file" name="avatar" accept="image/*" /><br/>

View file

@ -12,8 +12,13 @@
Your balance: {{ authuser.balance|chf }} Your balance: {{ authuser.balance|chf }}
<br/> <br/>
{# Links to deposit two common amounts of cash. TODO: Will be replaced by a nicer UI later (#20) #} {# Links to deposit two common amounts of cash. TODO: Will be replaced by a nicer UI later (#20) #}
<a href="/deposit?n=100">Deposit CHF 1</a><br/> <div class="thumblist-item">
<a href="/deposit?n=1000">Deposit CHF 10</a><br/> <a href="/deposit?n=100">Deposit CHF 1</a>
</div>
<div class="thumblist-item">
<a href="/deposit?n=1000">Deposit CHF 10</a>
</div>
<br/>
{% for product in products %} {% for product in products %}
{# Show an item per product, consisting of the name, image, price and stock, triggering a purchase on click #} {# Show an item per product, consisting of the name, image, price and stock, triggering a purchase on click #}
@ -26,9 +31,10 @@
{% else %} {% else %}
{{ product.price_non_member|chf }} {{ product.price_non_member|chf }}
{% endif %} {% endif %}
; Stock: {{ product.stock }}</span><br/> </span><br/>
<div class="imgcontainer"> <div class="imgcontainer">
<img src="/upload/thumbnails/products/{{ product.id }}.png" alt="Picture of {{ product.name }}"/> <img src="/static/upload/thumbnails/products/{{ product.id }}.png" alt="Picture of {{ product.name }}"/>
<span class="thumblist-stock">{{ product.stock }}</span>
</div> </div>
</a> </a>
</div> </div>

View file

@ -4,7 +4,7 @@
{{ super() }} {{ super() }}
<style> <style>
svg { svg {
width: 600px; width: 400px;
height: auto; height: auto;
} }
</style> </style>

View file

@ -14,7 +14,7 @@
<a href="/touchkey?uid={{ user.id }}&username={{ user.name }}"> <a href="/touchkey?uid={{ user.id }}&username={{ user.name }}">
<span class="thumblist-title">{{ user.name }}</span><br/> <span class="thumblist-title">{{ user.name }}</span><br/>
<div class="imgcontainer"> <div class="imgcontainer">
<img src="/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}"/> <img src="/static/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}"/>
</div> </div>
</a> </a>
</div> </div>