commit b7bc244f86f809950da662543ab73c83a12c128a Author: Valentin Wagner Date: Sat Feb 7 16:34:22 2026 +0100 initial commit diff --git a/.buildconfig b/.buildconfig new file mode 100644 index 0000000..faddb2f --- /dev/null +++ b/.buildconfig @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6b4d67 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/config/thewall.yml b/config/thewall.yml new file mode 100644 index 0000000..a7d6cab --- /dev/null +++ b/config/thewall.yml @@ -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 \ No newline at end of file diff --git a/database.sql b/database.sql new file mode 100644 index 0000000..73131d8 --- /dev/null +++ b/database.sql @@ -0,0 +1,3 @@ +CREATE DATABASE thewall; +CREATE USER thewall_user WITH PASSWORD 'thewall_pass'; +GRANT ALL PRIVILEGES ON DATABASE thewall TO thewall_user; diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..2e2ed3a --- /dev/null +++ b/init_db.py @@ -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() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f7bfe9b --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 0000000..e8c34c7 --- /dev/null +++ b/roadmap.md @@ -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 diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..85bfd5d --- /dev/null +++ b/tasks.py @@ -0,0 +1,9 @@ +from invoke import task + +@task +def deploy(c): + ... + +@task +def run(c): + c.run("python main.py") \ No newline at end of file diff --git a/thewall/__init__.py b/thewall/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/thewall/__main__.py b/thewall/__main__.py new file mode 100644 index 0000000..e69de29 diff --git a/thewall/__pycache__/db.cpython-313.pyc b/thewall/__pycache__/db.cpython-313.pyc new file mode 100644 index 0000000..6ccf609 Binary files /dev/null and b/thewall/__pycache__/db.cpython-313.pyc differ diff --git a/thewall/__pycache__/routes.cpython-313.pyc b/thewall/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000..d876625 Binary files /dev/null and b/thewall/__pycache__/routes.cpython-313.pyc differ diff --git a/thewall/__pycache__/settings.cpython-313.pyc b/thewall/__pycache__/settings.cpython-313.pyc new file mode 100644 index 0000000..7c548f8 Binary files /dev/null and b/thewall/__pycache__/settings.cpython-313.pyc differ diff --git a/thewall/__pycache__/views.cpython-313.pyc b/thewall/__pycache__/views.cpython-313.pyc new file mode 100644 index 0000000..ba82ddf Binary files /dev/null and b/thewall/__pycache__/views.cpython-313.pyc differ diff --git a/thewall/db.py b/thewall/db.py new file mode 100644 index 0000000..480cbce --- /dev/null +++ b/thewall/db.py @@ -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() diff --git a/thewall/helpers.py b/thewall/helpers.py new file mode 100644 index 0000000..fe96661 --- /dev/null +++ b/thewall/helpers.py @@ -0,0 +1,6 @@ +import hashlib + +def get_pwd_hash(pwd_string): + m = hashlib.sha256() + m.update(pwd_string) + return m.hexdigest() \ No newline at end of file diff --git a/thewall/main.py b/thewall/main.py new file mode 100755 index 0000000..c2af319 --- /dev/null +++ b/thewall/main.py @@ -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()) diff --git a/thewall/routes.py b/thewall/routes.py new file mode 100644 index 0000000..be16862 --- /dev/null +++ b/thewall/routes.py @@ -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/'), + ]) \ No newline at end of file diff --git a/thewall/settings.py b/thewall/settings.py new file mode 100644 index 0000000..9bd0980 --- /dev/null +++ b/thewall/settings.py @@ -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) \ No newline at end of file diff --git a/thewall/static/helpers.js b/thewall/static/helpers.js new file mode 100644 index 0000000..9a2452f --- /dev/null +++ b/thewall/static/helpers.js @@ -0,0 +1,11 @@ +function displayOverlay(html) { + +} + +function displaySubmissionForm() { + +} + +function displaySticker(Id) { + +} \ No newline at end of file diff --git a/thewall/static/logo1.png b/thewall/static/logo1.png new file mode 100644 index 0000000..b3114dd Binary files /dev/null and b/thewall/static/logo1.png differ diff --git a/thewall/static/logo1.svg b/thewall/static/logo1.svg new file mode 100644 index 0000000..281dae6 --- /dev/null +++ b/thewall/static/logo1.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + THEWALL + + diff --git a/thewall/static/style.css b/thewall/static/style.css new file mode 100644 index 0000000..c006353 --- /dev/null +++ b/thewall/static/style.css @@ -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; +} \ No newline at end of file diff --git a/thewall/templates/about.html b/thewall/templates/about.html new file mode 100644 index 0000000..49c8128 --- /dev/null +++ b/thewall/templates/about.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block content %} +
+

About

+
+

+ 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. +

+
+{% endblock content %} \ No newline at end of file diff --git a/thewall/templates/base.html b/thewall/templates/base.html new file mode 100644 index 0000000..a087a87 --- /dev/null +++ b/thewall/templates/base.html @@ -0,0 +1,28 @@ + + + The Wall + + + + + + +
+ +
+

The Wall

+
+
+
+ {% block content %}{% endblock %} +
+ + \ No newline at end of file diff --git a/thewall/templates/entry.html b/thewall/templates/entry.html new file mode 100644 index 0000000..460e6b6 --- /dev/null +++ b/thewall/templates/entry.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block content %} +
+ {{ entry.description }} +

Type: {{ entry.type.value }}

+

Description:

+

{{ entry.description }}

+

Information:

+

{{ entry.information }}

+

Found in: {{ entry.place_found }}

+

Found on: {{ entry.time_found }}

+

Uploaded at: {{ entry.time_uploaded }}

+

Uploaded by: {{ entry.uploaded_by }}

+
+{% endblock %} \ No newline at end of file diff --git a/thewall/templates/login.html b/thewall/templates/login.html new file mode 100644 index 0000000..dd959ae --- /dev/null +++ b/thewall/templates/login.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block content %} +
+

Login

+
+
+ + +
+ + +
+ +
+

By logging in you allow us the use of cookies.

+
+{% endblock %} \ No newline at end of file diff --git a/thewall/templates/sticker.html b/thewall/templates/sticker.html new file mode 100644 index 0000000..d9ce528 --- /dev/null +++ b/thewall/templates/sticker.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block content %} +
+ {{ entry.description }} +

Type: {{ entry.type.value }}

+

Description:

+

{{ entry.description }}

+

Information:

+

{{ entry.information }}

+

Found in: {{ entry.place_found }}

+

Found on: {{ entry.time_found }}

+

Uploaded at: {{ entry.time_uploaded }}

+
+{% endblock %} \ No newline at end of file diff --git a/thewall/templates/submission.html b/thewall/templates/submission.html new file mode 100644 index 0000000..5e35c3d --- /dev/null +++ b/thewall/templates/submission.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block content %} +
+

Submission

+
+
+ {% if submission_successful %} +

Your submission will be reviewed.

+ {% elif submission_successful == False %} +

{{ error_message }}

+ {% endif %} + +
+ +

+ + +

+ +
+ +

+ +
+ +

+ + +

+ + +

+ + +

+ +
+
+{% endblock %} \ No newline at end of file diff --git a/thewall/templates/wall.html b/thewall/templates/wall.html new file mode 100644 index 0000000..3e380c2 --- /dev/null +++ b/thewall/templates/wall.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block content %} +
+ {% for column in columns %} +
+ {% for entry in column %} + + + + {% endfor %} +
+ {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/thewall/views.py b/thewall/views.py new file mode 100644 index 0000000..5f31f9c --- /dev/null +++ b/thewall/views.py @@ -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 \ No newline at end of file