initial commit

This commit is contained in:
Valentin Wagner 2026-02-07 16:34:22 +01:00
commit b7bc244f86
31 changed files with 743 additions and 0 deletions

12
.buildconfig Normal file
View File

@ -0,0 +1,12 @@
[default]
name=Default
runtime=podman:340af3d12b0a26614807836780c2a5760e6ea9fcb912c2f800f618bd17c0464d
toolchain=default
config-opts=
run-opts=
prefix=/var/home/valentinw/Projects/.gnome-builder/projects/thewall-project/install/podman-340af3d12b0a26614807836780c2a5760e6ea9fcb912c2f800f618bd17c0464d
app-id=
postbuild=
prebuild=
run-command=
default=true

4
README.md Normal file
View File

@ -0,0 +1,4 @@
# Online Streetart Museum - The Wall
Sourcecode for www.thewall.org, the online museum for stickers and graffiti.
Written in Python using aiohttp, sqlalchemy and other libraries.

15
config/thewall.yml Normal file
View File

@ -0,0 +1,15 @@
# database settings
postgres:
database:
user:
password:
host:
port:
# login data for the initial admin level curator
admin:
name: admin
password: admin
# name used for the session cookies
cookie_name: THEWALL

3
database.sql Normal file
View File

@ -0,0 +1,3 @@
CREATE DATABASE thewall;
CREATE USER thewall_user WITH PASSWORD 'thewall_pass';
GRANT ALL PRIVILEGES ON DATABASE thewall TO thewall_user;

52
init_db.py Normal file
View File

@ -0,0 +1,52 @@
from sqlalchemy import create_engine, MetaData
from thewall.settings import config
import thewall.db as db
from thewall.helpers import get_pwd_hash
DSN = "postgresql://{user}:{password}@{host}:{port}/{database}"
def create_tables(engine):
meta = MetaData()
meta.create_all(bind=engine, tables=[db.entry, db.user])
def create_admin(engine):
conn = engine.connect()
conn.execute(db.user.insert(), {
'name': config['admin']['name'],
'pwd_hash': get_pwd_hash(config['admin']['password'].encode()),
'is_mod': True,
'is_admin': True
})
conn.close()
if __name__ == '__main__':
db_url = DSN.format(**config['postgres'])
engine = create_engine(db_url)
create_tables(engine)
create_admin(engine)
# conn.execute(question.insert(), [
# {'question_text': 'What\'s new?',
# 'pub_date': '2015-12-15 17:17:49.629+02'}
# ])
# conn.execute(choice.insert(), [
# {'choice_text': 'Not much', 'votes': 0, 'question_id': 1},
# {'choice_text': 'The sky', 'votes': 0, 'question_id': 1},
# {'choice_text': 'Just hacking again', 'votes': 0, 'question_id': 1},
# ])
#create first admin
# async with sa_session.begin():
# m = hashlib.sha256()
# m.update(app['config']['admin']['password'].encode())
# admin = User(
# name = app['config']['admin']['name'],
# pwd_hash = m.hexdigest(),
# is_mod = True,
# is_admin = True
# )
# sa_session.add(admin)
# await sa_session.commit()

28
requirements.txt Normal file
View File

@ -0,0 +1,28 @@
aiohappyeyeballs==2.4.4
aiohttp==3.11.11
aiohttp-jinja2==1.6
aiohttp-session==2.12.1
aiohttp-things==1.1.0
aiopg==1.4.0
aiosignal==1.3.2
async-timeout==4.0.3
attrs==24.3.0
cffi==1.17.1
cryptography==44.0.0
frozenlist==1.5.0
greenlet==3.1.1
idna==3.10
injector==0.14.1
invoke==2.2.0
Jinja2==3.1.5
MarkupSafe==3.0.2
multidict==6.1.0
pillow==11.1.0
propcache==0.2.1
psycopg2-binary==2.9.10
pycparser==2.22
PyYAML==6.0.2
SQLAlchemy==1.4.54
sqlalchemy-things==1.1.0
typing_extensions==4.12.2
yarl==1.18.3

20
roadmap.md Normal file
View File

@ -0,0 +1,20 @@
# Roadmap
## Alpha:
- ~~Submissions validieren (keine Zeiten aus der Zukunft etc.)~~
- ~~Maximale Upload Größe~~
- Thumbnail erzeugung und caching
- config.yml
- **Admin Modus**
- **Schönes CSS**
## Beta:
- Suche
- Popups/Overlays statt mehreren Seiten
- Infinite Scroll
## 1.0:
- Profanity Filter
- Docker Container
- Docker Compose
- Postgresql

9
tasks.py Normal file
View File

@ -0,0 +1,9 @@
from invoke import task
@task
def deploy(c):
...
@task
def run(c):
c.run("python main.py")

0
thewall/__init__.py Normal file
View File

0
thewall/__main__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

64
thewall/db.py Normal file
View File

@ -0,0 +1,64 @@
import enum
from sqlalchemy import *
import aiopg
__all__ = ['entry', 'user']
meta = MetaData()
class entry_type(enum.Enum):
Sticker = "Sticker"
Tag = "Tag"
Throwup = "Throwup"
entry = Table(
'entries', meta,
Column('id', Integer, primary_key=True),
Column('filename',String),
Column('type', Enum(entry_type)),
Column('description', String),
Column('information', String),
Column('place_found', String),
Column('time_found', Date),
Column('time_uploaded', DateTime),
Column('uploaded_by', String),
Column('approved', Boolean),
)
user = Table(
'users', meta,
Column('id', Integer, primary_key=True).
Column('name', String).
Column('pwd_hash', String),
Column('is_mod', Boolean),
Column('is_admin', Boolean)
)
user_sessions = Table(
'user_sessions', meta,
id = Column('id', Integer, primary_key=True),
user_id = Column('user_id', ForeignKey("users.id"))
)
async def pg_context(app):
conf = app['config']['postgres']
engine = await aiopg.sa.create_engine(
database=conf['database'],
user=conf['user'],
password=conf['password'],
host=conf['host'],
port=conf['port'],
minsize=conf['minsize'],
maxsize=conf['maxsize'],
)
app['db'] = engine
yield
app['db'].close()
await app['db'].wait_closed()

6
thewall/helpers.py Normal file
View File

@ -0,0 +1,6 @@
import hashlib
def get_pwd_hash(pwd_string):
m = hashlib.sha256()
m.update(pwd_string)
return m.hexdigest()

40
thewall/main.py Executable file
View File

@ -0,0 +1,40 @@
import pathlib
import base64
import jinja2
from aiohttp import web
from cryptography import fernet
import aiohttp_jinja2 as ahj2
import aiohttp_session as ahse
from settings import config
from db import pg_context
from routes import setup_routes
###############
# Run the app #
###############
async def app_factory():
app = web.Application()
app['config'] = config
fernet_key = fernet.Fernet.generate_key()
secret_key = base64.urlsafe_b64decode(fernet_key)
#TODO: use encrypted cookie storage
ahse.setup(app, ahse.SimpleCookieStorage(cookie_name=app['config']['cookie_name']))
#ahse.setup(app, ahse.EncryptedCookieStorage(secret_key, cookie_name=app['config']['cookie_name']))
setup_routes(app)
app.cleanup_ctx.append(pg_context)
ahj2.setup(app,
loader=jinja2.FileSystemLoader(f'{pathlib.Path(__file__).parent}/templates/'))
return app
if __name__ == '__main__':
web.run_app(app_factory())

18
thewall/routes.py Normal file
View File

@ -0,0 +1,18 @@
import pathlib
from aiohttp import web
from views import *
def setup_routes(app):
app.add_routes([
web.get('/', wall, name='index'),
web.get('/submission', submission_form),
web.post('/submission', handle_submission),
web.get('/login', login_form),
web.post('/login', handle_login),
web.get('/about', about),
web.get(r'/entry/{id:\d+}', entry),
web.post(r'/entry/{id:\d+}', handle_edit),
web.static('/static', f'{pathlib.Path(__file__).parent}/static/'),
])

12
thewall/settings.py Normal file
View File

@ -0,0 +1,12 @@
import pathlib
import yaml
BASE_DIR = pathlib.Path(__file__).parent.parent
config_path = BASE_DIR / 'config' / 'thewall.yml'
def get_config(path):
with open(path) as f:
config = yaml.safe_load(f)
return config
config = get_config(config_path)

11
thewall/static/helpers.js Normal file
View File

@ -0,0 +1,11 @@
function displayOverlay(html) {
}
function displaySubmissionForm() {
}
function displaySticker(Id) {
}

BIN
thewall/static/logo1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

109
thewall/static/logo1.svg Normal file
View File

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="309.60699mm"
height="90.546783mm"
viewBox="0 0 309.60699 90.546781"
version="1.1"
id="svg5"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="logo1.svg"
inkscape:export-filename="logo1.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="1.1893044"
inkscape:cx="636.92693"
inkscape:cy="138.73656"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(54.606487,-125.71584)">
<rect
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none"
id="rect1"
width="23.920801"
height="27.409267"
x="-54.566895"
y="125.75543" />
<rect
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none"
id="rect1-1"
width="23.920801"
height="27.409267"
x="-18.262875"
y="125.75584" />
<rect
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none"
id="rect1-7"
width="23.920801"
height="27.409267"
x="18.041145"
y="125.75584" />
<rect
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none"
id="rect1-2"
width="37.020275"
height="26.308609"
x="54.389526"
y="126.30615" />
<rect
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none"
id="rect1-72"
width="25.29184"
height="27.2801"
x="103.83738"
y="125.82042" />
<rect
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none"
id="rect1-26"
width="23.920801"
height="27.409267"
x="141.51244"
y="125.75584" />
<rect
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none"
id="rect1-10"
width="23.920984"
height="27.409252"
x="177.81647"
y="125.75589" />
<text
xml:space="preserve"
style="fill:#ffffff;fill-opacity:1;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:41.6789px;font-family:'Protest Guerrilla';-inkscape-font-specification:'Protest Guerrilla';text-align:center;letter-spacing:10.5833px;text-anchor:middle;stroke:none;stroke-width:1.95369;stroke-linecap:round;stroke-linejoin:bevel;paint-order:fill markers stroke"
x="63.3484"
y="115.64455"
id="text11176"
transform="matrix(1.1266111,0,0,0.88761774,0.60699319,50.000001)"><tspan
sodipodi:role="line"
id="tspan11174"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Protest Guerrilla';-inkscape-font-specification:'Protest Guerrilla';letter-spacing:10.5833px;fill:#ffffff;fill-opacity:1;stroke-width:1.95369"
x="68.640053"
y="115.64455"
dx="0">THEWALL</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

32
thewall/static/style.css Normal file
View File

@ -0,0 +1,32 @@
* {
margin: 0;
padding: 0;
font-family: Verdana, Tahoma, sans-serif;
}
body {
background-color: #fff;
background-size: 100% 100%;
background-attachment: fixed;
}
.wrapper {
margin: 10px;
max-width: 100%;
justify-content: center;
align-content: center;
display: flex;
}
.container {
background-color: #eee;
border: 2px solid rgb(15, 12, 7);
border-radius: 20px;
width: 60%;
padding: 20px;
display: flex;
}
.container hr {
margin: 0 10px;
}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<h2>About</h2>
<hr>
<p>
This Website is a collection of the many stickers, tags and other unpermitted streetart
our curators and volunteers can find.
The collection is moderated and we won't show any works that spread hatespeech
or violate any other content rules.
</p>
</div>
{% endblock content %}

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<head>
<title>The Wall</title>
<meta charset="utf-8">
<meta lang="eng">
<link rel="stylesheet" href="/static/style.css">
<script src="/static/helpers.js"></script>
</head>
<body>
<header>
<nav>
<a href="/">Wall</a>
<a href="/about">About</a>
<a href="/submission">Submit</a>
<!-- <button onclick="displaySubmissionForm()">Submit</button> -->
<a href="/login">Login</a>
</nav>
<div class="wrapper">
<h1>The Wall</h1>
</div>
</header>
<div class="wrapper">
{% block content %}{% endblock %}
</div>
<footer>
<!-- Copyright with autodate -->
</footer>
</body>

View File

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block content %}
<div id="sticker">
<img src="/images/{{ entry.id }}/{{ entry.filename }}" alt="{{ entry.description }}">
<p>Type: {{ entry.type.value }}</p>
<p>Description:</p>
<p>{{ entry.description }}</p>
<p>Information:</p>
<p>{{ entry.information }}</p>
<p>Found in: {{ entry.place_found }}</p>
<p>Found on: {{ entry.time_found }}</p>
<p>Uploaded at: {{ entry.time_uploaded }}</p>
<p>Uploaded by: {{ entry.uploaded_by }}</p>
</div>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<h2>Login</h2>
<hr>
<form action="/login" method="post" accept-charset="utf-8" enctype="multipart/form-data">
<label for="name">Username:</label>
<input type="text" id="username" name="username" value="">
<br>
<label for="password">Password:</label>
<input type="password" id="password" name="password">
<br>
<input type="submit" value="Login">
</form>
<p>By logging in you allow us the use of cookies.</p>
</div>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<div id="sticker">
<img src="/images/{{ entry.id }}/{{ entry.filename }}" alt="{{ entry.description }}">
<p>Type: {{ entry.type.value }}</p>
<p>Description:</p>
<p>{{ entry.description }}</p>
<p>Information:</p>
<p>{{ entry.information }}</p>
<p>Found in: {{ entry.place_found }}</p>
<p>Found on: {{ entry.time_found }}</p>
<p>Uploaded at: {{ entry.time_uploaded }}</p>
</div>
{% endblock %}

View File

@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<h2>Submission</h2>
<hr>
<form action="/submission" method="post" accept-charset="utf-8" enctype="multipart/form-data">
{% if submission_successful %}
<p>Your submission will be reviewed.</p>
{% elif submission_successful == False %}
<p>{{ error_message }}</p>
{% endif %}
<label for="image">Select an Image:</label>
<br>
<input type="file" id="image" name="image" value=""/>
<br><br>
<label for="type">Content type:</label>
<select id="type" name="type">
<option value="Sticker">Sticker</option>
<option value="Tag">Tag</option>
<option value="Throwup">Throwup</option>
</select>
<br><br>
<label for="description">Description:</label>
<br>
<textarea id="description" name="description" value=""></textarea>
<br><br>
<label for="information">Information:</label>
<br>
<textarea id="information" name="information" value=""></textarea>
<br><br>
<label for="place">Found at:</label>
<input type="text" id="place" name="place" value="">
<br><br>
<label for="date">Found on:</label>
<input type="date" id="date" name="date" value="">
<br><br>
<label for="place">Username:</label>
<input type="text" id="name" name="name" value="Anonymous">
<br><br>
<input type="submit" value="Submit">
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<div class="wall_row">
{% for column in columns %}
<div class="wall_column">
{% for entry in column %}
<a href="/sticker/{{ entry.id }}">
<img src="/images/{{ entry.id }}/thumbnail/{{ entry.filename }}">
</a>
{% endfor %}
</div>
{% endfor %}
</div>
{% endblock %}

164
thewall/views.py Normal file
View File

@ -0,0 +1,164 @@
import os
import datetime
import pathlib
import tempfile
import shutil
from PIL import Image, ImageOps
import aiohttp_jinja2 as ahj2
import aiohttp_session as ahse
import db
from helpers import get_pwd_hash
@ahj2.template('wall.html')
async def wall(request):
#TODO: render wall
async with request.app['db'].acquire() as conn:
...
conn.close()
return
@ahj2.template('entry.html')
async def entry(request):
async with request.app['db'].acquire() as conn:
cursor = await conn.execute(db.entry.select().where(db.entry.id == request.match_info['id']))
records = await cursor.fetchall()
# sa_session = ahsa.get_session(request)
# async with sa_session.begin():
# stmt = sa.select(entries).where(entries.id == request.match_info['id'])
# result = await sa_session.scalars(stmt)
return {'entry': records.all()[0]}
@ahj2.template('entry.html')
async def handle_edit(request):
#TODO
async with request.app['db'].acquire() as conn:
cursor = await conn.execute(db.entry.select().where(entry.id == request.match_info['id']))
records = await cursor.fetchall()
# sa_session = ahsa.get_session(request)
# async with sa_session.begin():
# stmt = sa.select(Entry).where(Entry.id == request.match_info['id'])
# result = await sa_session.scalars(stmt)
return {'entry': records.all()[0]}
@ahj2.template('submission.html')
async def submission_form(request):
#TODO: fill in username for Mods and Admins
return
@ahj2.template('submission.html')
async def handle_submission(request):
tf = tempfile.NamedTemporaryFile(mode='w+b', delete=False)
temp_file_name = tf.name
async for field in (await request.multipart()):
if field.name == 'image':
filename = field.filename
size = 0
while True:
chunk = await field.read_chunk() # 8192 bytes by default.
if not chunk:
break
size += len(chunk)
if size >= 2e7:
tf.close()
os.remove(temp_file_name)
return {'submission_successful': False, 'error_message': \
"Your file exceeds the maximum size of 20Mb."}
tf.write(chunk)
tf.close()
if field.name == 'type':
type = await field.read(decode=True)
if field.name == 'description':
desc = await field.read(decode=True)
if field.name == 'information':
info = await field.read(decode=True)
if field.name == 'place':
place = await field.read(decode=True)
if field.name == 'date':
date = await field.read(decode=True)
date = datetime.datetime.strptime(date.decode(), '%Y-%m-%d')
if date > datetime.datetime.now():
return {'submission_successful': False, 'error_message': \
"Due to the linear nature of time your submissions date of discovery is likely wrong."}
if field.name == 'name':
name = await field.read(decode=True)
#TODO: Tripcodes
#TODO
id = 0
async with request.app['db'].acquire() as conn:
await conn.execute(db.entry.insert({
'filename': filename,
'type': type.decode(),
'description': desc.decode(),
'information': info.decode(),
'place_found': place.decode(),
'time_found': date,
'time_uploaded': datetime.datetime.now(),
'uploaded_by': name.decode(),
'approved': False
}))
conn.close()
# sa_session = ahsa.get_session(request)
# async with sa_session.begin():
# entry = Entry(
# filename = filename,
# type = type.decode(),
# description = desc.decode(),
# information = info.decode(),
# place_found = place.decode(),
# time_found = date,
# time_uploaded = datetime.datetime.now(),
# uploaded_by = name.decode(),
# approved = False)
# sa_session.add(entry)
# await sa_session.commit()
# id = str(entry.id)
pathlib.Path(f"./images/{id}/thumbnail").mkdir(parents=True, exist_ok=True)
shutil.copy(temp_file_name, f"./images/{id}/{filename}")
os.remove(temp_file_name)
size = (200, 200)
with Image.open(f"./images/{id}/{filename}") as im:
ImageOps.contain(im, size).save(f"./images/{id}/thumbnail/{filename}")
return {'submission_successful': True}
@ahj2.template('login.html')
async def login_form(request):
return
@ahj2.template('login.html')
async def handle_login(request):
async for field in (await request.multipart()):
if field.name == 'username':
name = await field.read(decode=True)
if field.name == 'password':
pwd = await field.read(decode=True)
pwd_hash = get_pwd_hash(pwd)
#TODO
async with request.app['db'].acquire() as conn:
cursor = await conn.execute(db.user.select().where(db.user.name == name))
conn.close()
# sa_session = await ahsa.new_session(request)
# async with sa_session.begin():
# stmt = sa.select(User).where(User.name == name)
# result = await sa_session.scalars(stmt)
# # verify result
# if result.all()[0].pwd_hash == pwd_hash:
# se_session = ahse.new_session()
# # enter data into the cookie to reidentify the user
# location = request.app.router['index'].url_for()
# raise web.HTTPFound(location=location)
# else:
# return {'valid': False}
@ahj2.template('about.html')
async def about(request):
return