initial 1312 funktioniert ein bisschen

This commit is contained in:
2025-12-13 12:21:44 +01:00
commit 59dd267659
29 changed files with 973 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
from flask import Flask
app = Flask(__name__)
+190
View File
@@ -0,0 +1,190 @@
from flask import render_template, request, make_response
import database, re, bcrypt, secrets, time
from app import app
def get_session(request):
sessionid = request.cookies.get('session')
if not sessionid:
return False
sessionid = str(sessionid)
sessioninfo = database.fetch("""SELECT uid, created_at, expires, description
FROM session
WHERE value=?;""",
[sessionid])
if len(sessioninfo) == 0:
return False
if len(sessioninfo) > 1:
# there is a problem
return False
return sessioninfo[0] + (sessionid,)
@app.route("/auth/sessioninfo", methods=['GET'])
def sessioninfo():
time.sleep(secrets.randbelow(100)/50) # Rate limiting
sessioninfo = get_session(request)
if not sessioninfo:
return "Invalid session"
(uid, created_at, expires, description) = sessioninfo
# uid = sessioninfo[0][0]
# created_at = sessioninfo[0][1]
# expires = sessioninfo[0][2]
# description = sessioninfo[0][3]
return render_template("/auth/sessioninfo.html",
sessionid=sessionid,
uid=uid,
created_at=created_at,
expires=expires,
description=description)
@app.route("/auth/logout", methods=['GET'])
def logout():
sessioninfo = get_session(request)
response = make_response("Erfolgreich abgemeldet")
response.set_cookie('session', value='', expires=0)
if not sessioninfo:
return response
database.execute("DELETE FROM session WHERE value = ?", [sessioninfo[4]])
return response
@app.route("/auth/login", methods=['GET', 'POST'])
def login():
sessioninfo = get_session(request)
if sessioninfo:
uid = sessioninfo[0]
username = database.fetch("SELECT username FROM user WHERE uid = ?", [uid])
username = username[0][0]
return render_template("auth/loginsuccess.html", username=username)
if request.method == 'GET':
return render_template("auth/login.html",
response="",
username="")
username = _sanitizeUsername(request.form['user'])
if len(username) < 3:
return render_template("auth/login.html",
response=f"Der Nutzername {username} ist zu kurz.",
username=username)
userquery = database.fetch("SELECT uid, password FROM user WHERE username=?", [username])
# STOP TIMING ATTACKS
time.sleep(secrets.randbelow(100)/50)
if len(userquery) == 0:
return render_template("auth/login.html",
response=f"Der Nutzername >{username} oder das Passwort ist falsch.",
username=username)
uidquery = userquery[0][0]
passquery = userquery[0][1]
passform = str(request.form['pass'])
passform = passform.encode("utf8")
passcorrect = bcrypt.checkpw(passform, passquery)
if not passcorrect:
return render_template("auth/login.html",
response=f"Der Nutzername {username} oder das >Passwort ist falsch.",
username=username)
now = int(time.time())
timeout = int(request.form['timeout'])
timeout = now + timeout
session = secrets.randbelow(999999999)
session = str(session) + str(now)
database.execute("DELETE FROM session WHERE expires < ?", [str(now)])
database.execute("""INSERT INTO session
(uid, created_at, expires, description, value)
VALUES (?, ?, ?, ?, ?);""",
(uidquery, int(time.time()), timeout, "", session)
)
response = make_response(render_template("auth/loginsuccess.html", username=username))
response.set_cookie('session', value=session, expires=timeout)
return response
@app.route("/auth/newuser", methods=['GET', 'POST'])
def newuser():
if request.method == 'GET':
return render_template("auth/register.html",
responses=[],
username="")
success = True
responses = []
username = _sanitizeUsername(request.form['user'])
if len(username) < 3:
success = False
responses.append(f"Der Nutzername {username} ist zu kurz.")
usernamequery = database.fetch("SELECT id FROM user WHERE username=?", [username])
if len(usernamequery) != 0:
success = False
responses.append(f"Der Nutzername {username} wird bereits verwendet.")
pass1 = str(request.form['pass1'])
pass2 = str(request.form['pass2'])
if pass1 != pass2:
success = False
responses.append(f"Passwort und Passwortbestätigung sind ungleich.")
if len(pass1) < 10:
success = False
responses.append(f"Das Passwort ist kürzer als 10 Zeichen.")
if not success:
return render_template("auth/register.html",
responses=responses,
username=username)
uid = secrets.randbelow(99999999)
created_at = int(time.time())
# emailcode = secrets.randbelow(999999)
passsalt = bcrypt.gensalt()
passhash = bcrypt.hashpw(pass1.encode("utf8"), passsalt)
database.execute("""INSERT INTO user
(uid, created_at, username, password) VALUES
(?, ?, ?, ?)""",
(uid, created_at, username, passhash))
return render_template("auth/registersuccess.html", username=username)
def _sanitizeUsername(username):
username = str(username)
username = username.strip()
username = username[:32]
# Remove all non-word characters (everything except numbers and letters)
username = re.sub(r"[^._\w\s-]", '', username)
# Replace all runs of whitespace with a single dash
username = re.sub(r"\s+", '_', username)
return username
def _verifyEmail(email):
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if re.match(pattern, email):
return True
else:
return False
+21
View File
@@ -0,0 +1,21 @@
import sqlite3
def execute(query, data):
connection = sqlite3.connect("kver.db")
cursor = connection.cursor()
cursor.execute(query, data)
connection.commit()
connection.close()
def fetch(query, data):
connection = sqlite3.connect("kver.db")
cursor = connection.cursor()
result = cursor.execute(query, data)
result = result.fetchall()
connection.close()
return result
+31
View File
@@ -0,0 +1,31 @@
import sqlite3
from flask import render_template
from __main__ import app
@app.route("/database")
def database():
con = sqlite3.connect("kver.db")
cursor = con.cursor()
# cursor.execute("INSERT INTO movie VALUES (?, ?, ?)", ("Erster Film", 1900, 8.2))
# con.commit()
res = cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
return render_template("database.html", tables=res.fetchall())
# !!! USER VALUE IN SQL STATEMENT !!!
@app.route("/database/<table>")
def db_table(table):
con = sqlite3.connect("kver.db")
cursor = con.cursor()
# cursor.execute("INSERT INTO movie VALUES (?, ?, ?)", ("Erster Film", 1900, 8.2))
# con.commit()
# columns = cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name=?;", [table])
# !!! USER VALUE IN SQL-STATEMENT !!!
data = cursor.execute(f"SELECT * FROM {table};")
return render_template("database_table.html", data=data.fetchall())
+113
View File
@@ -0,0 +1,113 @@
from flask import render_template, request
from PIL import Image
# import database, re, bcrypt, secrets, time
import secrets, time, os, database
from auth import get_session
from app import app
@app.route("/entry/feed/<page>", methods=['GET'])
def feed(page):
page = int(page, 0)
page = max(0,page)
page = min(100,page)
count = 20
offset = page*count
posts = database.fetch("SELECT * FROM entry ORDER BY created_at DESC LIMIT ? OFFSET ?", [count, offset])
posts_with_usernames = []
for post in posts:
username = database.fetch("SELECT username FROM user WHERE uid = ?", [post[2]])
posts_with_usernames.append(post + username[0])
return render_template("entry/feed.html", posts=posts_with_usernames)
@app.route("/entry/create", methods=['GET', 'POST'])
def newentry():
session = get_session(request)
if not session:
return "Melde dich an, um einen Beitrag zu erstellen!"
uid = session[0]
if request.method == 'GET':
return render_template('entry/create.html')
content = str(request.form["content"])
content = content[:1000]
fileattached = False
if 'file' in request.files:
file = request.files['file']
fileattached=True
if fileattached:
fileattached = _storeimage(file)
if not fileattached and content == '':
return render_template('entry/create.html', responses=["Leerer Beitrag!"])
if not fileattached:
fileattached = "none"
pid = secrets.randbelow(999999999999)
now = int(time.time())
database.execute("""INSERT INTO entry
(pid, uid, created_at, content, filepath)
VALUES (?, ?, ?, ?, ?)""",
(pid, uid, now, content, fileattached))
return render_template('entry/create.html', responses=[content, str(fileattached)])
def _storeimage(file):
maxsize = 1024
tmpfile = secrets.randbelow(999999)
tmppath = f"tmp/{tmpfile}"
import resizegif
try:
file.save(tmppath)
i = Image.open(tmppath)
if i.format == 'GIF':
filetype = 'gif'
else:
i = i.convert("RGB")
filetype = 'jpg'
newpath = f"static/media/{int(time.time())}-{tmpfile}.{filetype}"
(width, height) = i.size;
aspect = width/height
if width < maxsize and height < maxsize:
if filetype == 'gif':
resizegif.resize_gif(i, newpath, (width, height))
else:
i.save(newpath)
os.remove(tmppath)
return newpath
if width > height:
width = maxsize
height = maxsize/aspect
else:
height = maxsize
width = aspect*maxsize
if filetype == 'gif':
resizegif.resize_gif(i, newpath, (width, height))
else:
i.thumbnail((width, height), Image.Resampling.LANCZOS)
i.save(newpath)
os.remove(tmppath)
return newpath
except Exception as e:
print(e)
os.remove(tmppath)
return False
+36
View File
@@ -0,0 +1,36 @@
import os
from PIL import Image
from flask import request, render_template
from __main__ import app
@app.route("/upload", methods=['GET', 'POST'])
def upload():
state="Nothing done..."
success=False
if request.method == 'POST':
if 'file' not in request.files:
state='No file'
file = request.files['file']
if file.filename == '':
state="No File selected!"
if file:
filename = "uploadedfile"
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
state="File uploaded!"
success=True
imagedata = []
if success:
try:
i = Image.open("uploads/uploadedfile")
imagedata.append(i.format)
imagedata.append(i.size)
except Exception as e:
imagedata.append(e)
return render_template("upload.html", state=state, imagedata=imagedata)
+38
View File
@@ -0,0 +1,38 @@
import os
from flask import Flask, request, render_template
from datetime import datetime
from werkzeug.exceptions import HTTPException
from app import app
app.config['UPLOAD_FOLDER'] = "uploads/"
@app.errorhandler(400)
def handle_bad_request(e):
print(f"400 BAD REQUEST: {e}")
return '400<br>BAD REQUEST'
@app.template_filter('timestamp')
def timestamp_filter(value):
try:
dt = datetime.fromtimestamp(value)
return dt.strftime("%d.%m.%Y %H:%M")
except:
return value
# import sendmail
import auth
# import dbcontroll
# import imageupload
import entry
@app.route("/header")
def header():
return render_template("header.html")
@app.route("/")
def template():
return render_template("index.html", name="Hmmm")
if __name__ == "__main__":
app.run(debug=True)
+44
View File
@@ -0,0 +1,44 @@
# Source - https://stackoverflow.com/a
# Posted by brvoisin
# Retrieved 2025-12-13, License - CC BY-SA 4.0
"""
# Resize an animated GIF
Inspired from https://gist.github.com/skywodd/8b68bd9c7af048afcedcea3fb1807966
Useful links:
* https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#saving
* https://stackoverflow.com/a/69850807
Example:
```
python resize_gif.py input.gif output.gif 400,300
```
"""
import sys
from PIL import Image
from PIL import ImageSequence
def resize_gif(input_image, output_path, max_size):
frames = list(_thumbnail_frames(input_image, max_size))
output_image = frames[0]
output_image.save(
output_path,
save_all=True,
append_images=frames[1:],
disposal=input_image.disposal_method,
**input_image.info,
)
def _thumbnail_frames(image, max_size):
for frame in ImageSequence.Iterator(image):
new_frame = frame.copy()
new_frame.thumbnail(max_size, Image.Resampling.LANCZOS)
yield new_frame
if __name__ == "__main__":
max_size = [int(px) for px in sys.argv[3].split(",")] # "150,100" -> (150, 100)
resize_gif(sys.argv[1], sys.argv[2], max_size)
+41
View File
@@ -0,0 +1,41 @@
import smtplib, ssl, database, time
def send(subject, message, recipient, type):
hour_ago = int(time.time()) - 3600
count = database.fetch("SELECT COUNT(*) FROM email WHERE time > ?", [hour_ago])
if count[0][0] > 5:
return False
database.execute("INSERT INTO email (recipient, time, type) VALUES (?, ?, ?)", [
recipient,
int(time.time()),
type
])
smtp_server = "webspace29.do.de"
port = 587 # For starttls
sender_email = "system@bnd.wtf"
password = ""
message = f"""From: system@bnd.wtf\nSubject: {subject}
{message}"""
# Create a secure SSL context
context = ssl.create_default_context()
# Try to log in to server and send email
try:
server = smtplib.SMTP(smtp_server,port)
server.ehlo() # Can be omitted
server.starttls(context=context) # Secure the connection
server.ehlo() # Can be omitted
server.login(sender_email, password)
server.sendmail(sender_email, recipient, message)
# TODO: Send email here
except Exception as e:
# Print any error messages to stdout
print(e)
finally:
server.quit()
return True
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

+110
View File
@@ -0,0 +1,110 @@
html {
border: 1px solig gray;
font-family: "DejaVu Serif"; }
div.entry {
border: 1px solid gray;
padding: 10px 15px;
margin: 0px; }
div.entry div.entrymeta {
display: flex;
justify-content: left;
align-items: center;
gap: 15px;
cursor: pointer; }
div.entry div.entrymeta span.time {
color: gray; }
div.entry div.entrymeta span.user {
text-decoration: underline; }
div.entry div.entrymeta img.userimage {
width: 80px; }
div.entry div.contents p {
margin: 8pt 0pt; }
div.entry img {
max-width: 100%; }
div.entry.comment {
border: none;
padding: 0px 15px;
margin: 0px;
display: flex;
gap: 5px; }
div.entry.comment .left {
width: 70px;
display: flex;
flex-direction: column; }
div.entry.comment .left .spacer {
background-color: black;
height: 100%;
width: 6%;
margin: 0px 47%; }
form {
display: flex;
flex-direction: column;
width: 400px;
max-width: 100%; }
form textarea {
resize: vertical;
font-size: 1.0rem; }
form.create {
border: 1px solid gray;
padding: 10px 15px;
margin: 0px; }
form hr {
width: 100%; }
input,
textarea,
button,
select {
padding: 4pt 6pt;
margin: 3pt 0pt;
border: 2px solid black;
cursor: pointer;
box-shadow: 0px 0px; }
input:hover,
textarea:hover,
button:hover,
select:hover {
box-shadow: 1px 1px; }
input:focus,
textarea:focus,
button:focus,
select:focus {
box-shadow: 3px 3px black;
outline: none; }
button {
background-color: #ffabcd; }
#response div {
padding: 4pt 6pt; }
#response .error {
color: red; }
.loading {
background-color: #fffa;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-content: center;
transition: background-color 5s;
transition-timing-function: ease-in; }
.post {
border: 1px solid black;
padding: 15px 15px;
margin: 10px 0px; }
.post img {
max-width: 100%;
max-height: 800px; }
.post .meta {
color: gray; }
.post .content {
margin: 10px 0px; }
+178
View File
@@ -0,0 +1,178 @@
// @font-face {
// font-family: 'avenir';
// src: url('/static/avenir-font/AvenirLTStd-Medium.otf');
// font-weight: normal;
// font-style: normal;
// }
html {
// font-family: "avenir";
// font-weight: 400;
// font-style: normal;
// background-color: #e9e9ed;
border: 1px solig gray;
// border-radius: 5px;
font-family: "DejaVu Serif";
}
div.entry {
// background-color: green;
border: 1px solid gray;
padding: 10px 15px;
margin: 0px;
div.entrymeta {
display: flex;
justify-content: left;
align-items: center;
gap: 15px;
cursor: pointer;
span.time {
// background-color: red;
color: gray;
}
span.user {
// background-color: blue;
text-decoration: underline;
}
img.userimage {
width: 80px;
}
}
div.contents {
// background-color: yellow;
p {
// background-color: orange;
margin: 8pt 0pt;
}
}
img {
max-width: 100%;
}
&.comment {
border: none;
padding: 0px 15px;
margin: 0px;
display: flex;
gap: 5px;
.left {
width: 70px;
display: flex;
flex-direction: column;
// background-color: green;
.spacer {
background-color: black;
height: 100%;
width: 6%;
margin: 0px 47%;
}
}
// .right {
// background-color: red;
// }
// .contents {
// display: flex;
// }
}
}
form {
display: flex;
flex-direction: column;
width: 400px;
max-width: 100%;
textarea {
// width: 100%;
resize: vertical;
font-size: 1.0rem;
}
&.create {
border: 1px solid gray;
padding: 10px 15px;
margin: 0px;
}
hr {
width: 100%;
}
}
input,
textarea,
button,
select {
padding: 4pt 6pt;
margin: 3pt 0pt;
border: 2px solid black;
cursor: pointer;
box-shadow: 0px 0px;
&:hover {
box-shadow: 1px 1px;
}
&:focus {
box-shadow: 3px 3px black;
outline: none;
}
}
button {
background-color: #ffabcd;
}
#response {
div {
padding: 4pt 6pt;
}
.error {
color: red;
}
}
.loading {
// background-image: url('/static/spinner.gif');
background-color: #fffa;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-content: center;
transition: background-color 5s;
transition-timing-function: ease-in;
}
.post {
border: 1px solid black;
padding: 15px 15px;
margin: 10px 0px;
img {
max-width: 100%;
max-height: 800px;
}
.meta {
color: gray;
}
.content {
margin: 10px 0px;
}
}
+3
View File
@@ -0,0 +1,3 @@
$(() => {
$('#auth-visible').hide()
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

+17
View File
@@ -0,0 +1,17 @@
<h2>Einloggen</h2>
<form hx-post="/auth/login" hx-target="#auth-rendertarget" hx-indicator=".loading">
<input type="text" name="user" placeholder="Nutzername" value="{{ username }}"><br>
<input type="password" name="pass" placeholder="Passphrase"><br>
<label>Automatisch abmelden in:</label>
<select name="timeout">
<option value="1200">20 Minuten</option>
<option value="3600" selected="selected">1 Stunde</option>
<option value="18000">5 Stunden</option>
<option value="86400">24 Stunden</option>
<option value="31536000">1 Jahr</option>
</select>
<button type="submit">Anmelden</button>
<div>
{{response}}
</div>
</form>
+3
View File
@@ -0,0 +1,3 @@
<h1>Willkommen, {{username}}!</h1>
<button hx-get="/auth/logout" hx-swap="outerHTML">Abmelden...</button>
+15
View File
@@ -0,0 +1,15 @@
<h2>Neuen Account registrieren</h2>
<form hx-post="/auth/newuser" hx-target="#auth-rendertarget" method="post">
<input type="text" name="user" placeholder="Nutzername" value="{{ username }}"><br>
<input type="password" name="pass1" placeholder="Passphrase"><br>
<input type="password" name="pass2" placeholder="Passphrase wiederholen"><br>
<label><input type="checkbox" name="pass2"> Ich bin mit der <a href="">Datenschutzerklärung</a> einverstanden!</label>
<button type="submit">Account registrieren!</button>
<div>
{% for r in responses %}
{{ r }} <br>
{% endfor %}
</div>
</form>
+1
View File
@@ -0,0 +1 @@
<h2>Willkommen im Kontrollverlust!</h2>
+5
View File
@@ -0,0 +1,5 @@
Sessionid: {{ sessionid }} <br>
UID: {{ uid }} <br>
Created: {{ created_at | timestamp }} <br>
Expires: {{ expires | timestamp }} <br>
Description: {{ description }} <br>
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/static/css/main.css">
<script
src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"
integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz"
crossorigin="anonymous">
</script>
<script
src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="
crossorigin="anonymous">
</script>
<script src="/static/js/main.js"></script>
</head>
<body>
<a href="/docs/impressum.html">Impressum</a>
<a href="/docs/datenschutz.html">Datenschutz</a>
<a href="/docs/kontakt.html">Kontakt</a>
<div class="htmx-indicator loading"></div>
<h1>Kontrollverlust</h1>
<span>Verbuggte scheise hier. Komm hack mich doch, trau dich!</span>
<div hx-get="/header" hx-trigger="load"></div>
{% block content %}{% endblock %}
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Database</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
{% for tablename in tables %}
<a href="/database/{{ tablename[0] }}">{{ tablename[0] }}</a><br>
{% endfor %}
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
<table>
{% for row in data %}
<tr>
{% for column in row %}
<td>{{ column }}</td>
{% endfor %}
</tr>
{% endfor %}
</table>
+11
View File
@@ -0,0 +1,11 @@
<form hx-post="/entry/create" hx-target="#entry-rendertarget" method="post" enctype="multipart/form-data">
<textarea name="content"></textarea>
<input type="file" name="file">
<button type="submit">Beitrag erstellen!</button>
<div>
{% for r in responses %}
{{ r }} <br>
{% endfor %}
</div>
</form>
+15
View File
@@ -0,0 +1,15 @@
{% for p in posts %}
<div class="post">
<div class="meta">
{{ p[6] }} - {{ p[3] | timestamp }} <br>
</div>
<div class="content">
{{ p[4] }}
</div>
{% if p[5] != 'none' %}
<img src="/{{ p[5] }}">
{% endif %}
</div>
{% endfor %}
+6
View File
@@ -0,0 +1,6 @@
<button onclick="$('#auth-visible').show()" hx-get="/auth/login" hx-target="#auth-rendertarget">Anmelden</button>
<button onclick="$('#auth-visible').show()" hx-get="/auth/newuser" hx-target="#auth-rendertarget">Account erstellen</button>
<div id="auth-visible">
<div id="auth-rendertarget"></div>
<button onclick="$('#auth-visible').hide()">Abbrechen</button>
</div>
+15
View File
@@ -0,0 +1,15 @@
{% extends "baseof.html" %}
{% block title %}Index{% endblock %}
{% block content %}
<hr>
<div hx-get="/entry/create" hx-trigger="load" id="entry-rendertarget"></div>
<hr>
<div hx-get="/entry/feed/0" hx-trigger="load, every 5s"></div>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>My new Site!</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Upload new File</h1>
<span>{{ state }}</span>
<form method="post" enctype="multipart/form-data">
<input type=file name=file>
<input type=submit value=Upload>
</form>
{% for id in imagedata %}
{{ id }}
{% endfor %}
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB