4 Commits

Author SHA1 Message Date
Michachatz 2d4d48c45c Fixed the command 2026-05-11 11:58:33 +02:00
Michatec 5febf7e64d feat(shop): implement CSRF protection and improve UI/UX
- Add CSRF token validation to the shop purchase process to prevent cross-site request forgery.
- Implement a unique constraint on `UserShopItem` to prevent duplicate purchases of the same item.
- Refactor the shop template with a modern, responsive grid layout and improved visual feedback for owned items.
- Enhance CSS with better dark/light mode support, including improved navbar styling and scrollbar customization.
- Add `.env_example` and update documentation for environment variable setup.
- Integrate `python-dotenv` for environment variable management.
- Improve logging configuration for the application.
- Update `.gitignore` to include `venv/` and `.env`.

Co-authored-by: Copilot <copilot@github.com>
2026-04-26 20:49:42 +02:00
Michachatz 7f8948bba9 Add print statement for serving connections 2026-04-26 15:37:30 +02:00
Michachatz 2b93ae73ff Fix typo in requirements.txt filename 2026-04-26 15:23:25 +02:00
9 changed files with 350 additions and 96 deletions
+3
View File
@@ -0,0 +1,3 @@
# You can change this file, to your preferred Settings.
PORT=YOUR PORT
+2 -3
View File
@@ -10,11 +10,10 @@ migrations
*.env
.vscode
routes/__pycache__
tools
*.pot
*.mo
routes/oauth.py
static/profile_pics
static/uploads
commands.txt
py-to-exemfc.json
venv/
.env
+12 -3
View File
@@ -30,15 +30,24 @@ MiniFacebook is a minimalist social network built with [Flask](https://flask.pal
2. **Install dependencies**
```sh
pip install -r requirments.txt
pip install -r requirements.txt
```
3. **Start**
3. **Setup the .env file**
```sh
mv .env_example .env
```
And you can change this file, to your preferred Settings.
⚠️ You need to set up a PORT
4. **Start**
```sh
python main.py
```
4. **Optional:**
5. **Optional:**
Go to routes/example_oauth.py
+130 -50
View File
@@ -1,4 +1,4 @@
from flask import Flask, request, render_template, redirect, url_for, flash, abort, jsonify
from flask import Flask, request, render_template, redirect, url_for, flash, abort, jsonify, current_app, session, has_request_context
from flask_migrate import Migrate
from flask_login import LoginManager, login_required, current_user
from werkzeug.security import generate_password_hash
@@ -20,8 +20,14 @@ try:
from routes.oauth import oauth
except ImportError:
pass
from dotenv import load_dotenv
import logging
import re
import os
import os, sys
logger = logging.getLogger('waitress')
logger.setLevel(logging.INFO)
logger.addHandler(logging.StreamHandler(sys.stdout))
__mapper_args__ = {"confirm_deleted_rows": False}
@@ -66,40 +72,12 @@ app.register_blueprint(friends_bp)
app.register_blueprint(noti_bp)
app.register_blueprint(credits_bp)
with app.app_context():
if db.session.query(ShopItem).count() == 0:
db.session.add(ShopItem(
name="Premium Account",
description="Exclusive features and content.",
price=100,
icon="bi-star"
))
db.session.add(ShopItem(
name="Gold Profile Frame",
description="Adds a golden profile frame to your profile.",
price=50,
icon="bi-person-bounding-box"
))
db.session.add(ShopItem(
name="Extra Upload Slot",
description="Become able to upload more files.",
price=130,
icon="bi-cloud-upload"
))
db.session.add(ShopItem(
name="More Types",
description="More types for your posts. Limit: 500 types per post.",
price=80,
icon="bi-megaphone"
))
db.session.commit()
else:
pass
def get_locale():
lang = request.cookies.get('lang')
if lang in ['de', 'en']:
return lang
if has_request_context():
lang = request.cookies.get('lang')
if lang in ['de', 'en']:
return lang
return None
babel.init_app(app, locale_selector=get_locale)
@@ -188,23 +166,48 @@ def setup():
@app.route('/shop', methods=['GET', 'POST'])
@login_required
def shop():
items = db.session.query(ShopItem).all()
message = None
if 'shop_csrf_token' not in session:
session['shop_csrf_token'] = os.urandom(24).hex()
csrf_token_value = session['shop_csrf_token']
items = db.session.query(ShopItem).order_by(ShopItem.price.asc()).all()
owned_ids = [usi.item_id for usi in current_user.shop_items]
user_points = current_user.reward_points()
if request.method == 'POST':
item_id = int(request.form['item_id'])
if request.form.get('csrf_token') != session.get('shop_csrf_token'):
logger.warning(f"CSRF token mismatch for user {current_user.id} in shop")
abort(403)
try:
item_id = int(request.form['item_id'])
except (ValueError, TypeError):
flash(_('Invalid item selected.'), 'danger')
return redirect(url_for('shop'))
item = db.session.get(ShopItem, item_id)
if item_id in owned_ids:
message = _("Already purchased!")
elif item and current_user.reward_points() >= item.price:
db.session.add(Reward(user_id=current_user.id, type=f'buy_{item.name}', points=-item.price))
db.session.add(UserShopItem(user_id=current_user.id, item_id=item.id))
db.session.commit()
message = _(f"Purchased: {item.name}")
owned_ids.append(item_id)
if not item:
flash(_('Item not found.'), 'danger')
elif item_id in owned_ids:
flash(_('Already purchased!'), 'warning')
elif user_points < item.price:
flash(_('Not enough points! You need %(needed)d more.', needed=item.price - user_points), 'danger')
else:
message = _("Not enough points!")
return render_template('shop.html', items=items, message=message, owned_ids=owned_ids)
try:
db.session.add(Reward(user_id=current_user.id, type=f'buy_{item.name}', points=-item.price))
db.session.add(UserShopItem(user_id=current_user.id, item_id=item.id))
db.session.commit()
session['shop_csrf_token'] = os.urandom(24).hex()
flash(_('Successfully purchased: %(item_name)s', item_name=item.name), 'success')
return redirect(url_for('shop'))
except Exception as e:
db.session.rollback()
logger.error(f"Shop purchase failed for user {current_user.id}: {e}")
flash(_('Purchase failed. Please try again.'), 'danger')
return render_template('shop.html', items=items, owned_ids=owned_ids,
user_points=user_points, csrf_token_value=csrf_token_value)
@app.errorhandler(403)
def forbidden(error):
@@ -217,8 +220,85 @@ def not_found(error):
return redirect(url_for('post.feed'))
return render_template('index.html'), 200
def print_error(message):
print(f"\033[91m[ERROR] {message}\033[0m")
def print_loading(message):
colors = ["\033[91m", "\033[93m", "\033[92m", "\033[96m", "\033[94m", "\033[95m"]
color = colors[hash(message) % len(colors)]
print(f"{color}{message}...\033[0m")
def print_success(message):
colors = ["\033[92m", "\033[96m", "\033[94m", "\033[95m"]
color = colors[hash(message) % len(colors)]
print(f"{color}{message}\033[0m")
def print_rainbow_separator():
rainbow = "\033[91m▆\033[93m▆\033[92m▆\033[96m▆\033[94m▆\033[95m▆\033[0m"
print(f" {rainbow * 12}")
if __name__ == '__main__':
print_loading("Starting MiniFaceBook...")
print_rainbow_separator()
print_loading("Initializing database")
try:
serve(app, host="0.0.0.0", port=80, threads=12)
with app.app_context():
if db.session.query(ShopItem).count() == 0:
shop_items = [
{
'name': _('Premium Account'),
'description': _('Exclusive features and content.'),
'price': 100,
'icon': 'bi-star'
},
{
'name': _('Gold Profile Frame'),
'description': _('Adds a golden profile frame to your profile.'),
'price': 50,
'icon': 'bi-person-bounding-box'
},
{
'name': _('Extra Upload Slot'),
'description': _('Become able to upload more files.'),
'price': 130,
'icon': 'bi-cloud-upload'
},
{
'name': _('More Types'),
'description': _('More types for your posts. Limit: 500 types per post.'),
'price': 80,
'icon': 'bi-megaphone'
}
]
for item_data in shop_items:
item = ShopItem(**item_data)
db.session.add(item)
db.session.commit()
print_success("Database initialized successfully.")
except Exception as e:
print_error(f"Database initialization failed: {e}")
print_error("Please check your database configuration and ensure the database is accessible.")
sys.exit(1)
print_loading("Loading environment variables")
try:
load_dotenv()
port = os.environ.get('PORT')
print_success("Environment variables loaded successfully.")
except Exception as e:
print_error(f"Failed to load environment variables: {e}")
print_error("Please set the environment variables!")
sys.exit(1)
print_loading(f"Using port {port}")
print_rainbow_separator()
print_loading("Starting server with Waitress")
try:
print_success(f"Server started successfully with Waitress at the port {port}.")
serve(app, host="0.0.0.0", port=port, threads=12, connection_limit=1000)
except:
app.run(debug=True, host="0.0.0.0", port=80)
print_error(f"Failed to start with Waitress, falling back to Flask's built-in server at port {port}. This is not recommended for production use.")
app.run(debug=True, host="0.0.0.0", port=port)
+2
View File
@@ -119,6 +119,8 @@ class UserShopItem(db.Model):
item_id = db.Column(db.Integer, db.ForeignKey('shop_item.id'))
bought_at = db.Column(db.DateTime, default=datetime.now)
item = db.relationship('ShopItem')
__table_args__ = (db.UniqueConstraint('user_id', 'item_id', name='unique_user_item_purchase'),)
class SupportRequest(db.Model):
id = db.Column(db.Integer, primary_key=True)
+1
View File
@@ -7,3 +7,4 @@ waitress
authlib
sqlalchemy
requests
dotenv
+89 -13
View File
@@ -5,6 +5,16 @@ body {
cursor: url("/static/icons/custom-cursor.png"), auto;
}
body *::selection {
cursor: text;
}
input::selection, textarea::selection {
cursor: text;
}
body input, body textarea {
cursor: auto;
}
canvas {
background: black;
border: 1px solid white;
@@ -14,12 +24,31 @@ canvas {
margin-bottom: 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
background: #fff;
border: none;
}
.navbar-brand {
font-weight: bold;
color: #2563eb !important;
letter-spacing: 1px;
}
/* Navbar in dark mode */
body.dark-mode .navbar {
background: #1f2937 !important;
border-color: #374151 !important;
}
body.dark-mode .navbar .nav-link {
color: #e5e7eb !important;
}
body.dark-mode .navbar .nav-link:hover {
color: #60a5fa !important;
}
body.dark-mode .navbar-toggler {
border-color: #4b5563 !important;
}
body.dark-mode .navbar-toggler-icon {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(229,231,235,0.9)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E") !important;
}
.profile-pic {
width: 56px;
height: 56px;
@@ -120,29 +149,65 @@ canvas {
border-radius: 8px;
}
/* Light mode scrollbar */
body.light-mode ::-webkit-scrollbar {
background: #d1d5db;
}
body.light-mode ::-webkit-scrollbar-thumb {
background: #9ca3af;
border-radius: 8px;
}
/* Light Mode (default) */
body, .card, .navbar, .list-group-item, .table, .form-control, .form-select {
transition: background 0.3s, color 0.3s;
}
body.light-mode {
background: linear-gradient(135deg, #0f5b86 0%, #0245aabb 100%);
background: linear-gradient(135deg, #f0f2f5 0%, #e8ecf0 100%);
color: #222;
}
body.light-mode .card,
body.light-mode .navbar,
body.light-mode .list-group-item,
body.light-mode .table {
background: #fff;
color: #222;
}
body.light-mode .navbar {
background: #f8fafc !important;
border-color: #e5e7eb !important;
color: #222;
}
body.light-mode .navbar .nav-link {
color: #374151 !important;
}
body.light-mode .navbar .nav-link:hover {
color: #2563eb !important;
}
body.light-mode .form-control,
body.light-mode .form-select {
background: #f8fafc;
background: #fff;
color: #222;
border-color: #d1d5db;
}
body.light-mode .form-control:focus,
body.light-mode .form-select:focus {
background: #fff;
border-color: #2563eb;
box-shadow: 0 0 0 2px #2563eb22;
}
body.light-mode li button {
color: #0011ff !important;
color: #2563eb !important;
}
/* Links in light mode */
body.light-mode a,
body.light-mode a:visited {
color: #2563eb !important;
text-decoration: none;
}
body.light-mode a:hover {
color: #1d4ed8 !important;
}
/* Dark Mode */
@@ -155,9 +220,6 @@ body.dark-mode li button {
color: #0099f1 !important;
}
body.light-mode p {
color: #000000 !important;
}
body.dark-mode .card,
body.dark-mode .navbar,
@@ -177,9 +239,14 @@ body.dark-mode .form-control:focus,
body.dark-mode .form-select:focus {
border-color: #2563eb !important;
box-shadow: 0 0 0 2px #2563eb55 !important;
background: #23272f !important;
background: #1f2937 !important;
color: #e5e7eb !important;
}
body.light-mode .form-control:focus,
body.light-mode .form-select:focus {
background: #fff !important;
}
body.dark-mode .btn,
body.dark-mode .btn-primary,
body.dark-mode .btn-success,
@@ -226,10 +293,10 @@ body.dark-mode .profile-pic {
body.dark-mode .table th,
body.dark-mode .table td {
color: #e5e7eb !important;
background: #23272f !important;
background: #1f2937 !important;
}
body.dark-mode .list-group-item {
background: #23272f !important;
background: #1f2937 !important;
color: #e5e7eb !important;
box-shadow: 0 1px 4px rgba(37,99,235,0.08) !important;
}
@@ -271,17 +338,26 @@ body.dark-mode .navbar-toggler-icon {
border-top: 1px solid #e5e7eb;
}
body.light-mode .footer {
background: #f8fafc !important;
border-top: 1px solid #e5e7eb !important;
}
body.dark-mode .footer {
background: #181a1b !important;
border-top: 1px solid #23272f !important;
background: #1f2937 !important;
border-top: 1px solid #374151 !important;
}
.footer .text-muted, .footer a {
color: #6c757d !important;
}
body.light-mode .footer .text-muted, body.light-mode .footer a {
color: #6b7280 !important;
}
body.dark-mode .footer .text-muted, body.dark-mode .footer a {
color: #b0b8c1 !important;
color: #9ca3af !important;
}
@media (max-width: 576px) {
+1 -1
View File
@@ -24,7 +24,7 @@
</script>
</head>
<body class="d-flex flex-column min-vh-100 {{ theme_class }}">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<nav class="navbar navbar-expand-lg bg-light" id="main-navbar">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('index') }}">
<i class="bi bi-people-fill me-2"></i>MiniFacebook
+110 -26
View File
@@ -1,33 +1,117 @@
{% extends "base.html" %}
{% block title %}{{ _('Shop') }}{% endblock %}
{% block content %}
<h2><i class="bi bi-shop me-2"></i>{{ _('Shop') }}</h2>
<p>{{ _('Deine Reward-Punkte:') }} <b>{{ current_user.reward_points() }}</b></p>
{% if message %}
<div class="alert alert-info">{{ message }}</div>
{% endif %}
<div class="row">
{% for item in items %}
<div class="col-md-4 mb-3">
<div class="card shadow-sm">
<div class="card-body text-center">
<i class="bi {{ item.icon }} display-4 mb-2"></i>
<h5>{{ item.name }}</h5>
<p>{{ item.description }}</p>
<p><b>{{ item.price }}</b> {{ _('Points') }}</p>
{% if item.id in owned_ids %}
<button class="btn btn-secondary" disabled>{{ _('Bought') }}</button>
{% else %}
<form method="post">
<input type="hidden" name="item_id" value="{{ item.id }}">
<button class="btn btn-success" {% if current_user.reward_points() < item.price %}disabled{% endif %}>
<i class="bi bi-cart"></i> {{ _('Buy') }}
</button>
</form>
{% endif %}
<div class="container-fluid px-0">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-shop me-2"></i>{{ _('Shop') }}</h2>
<div class="d-flex align-items-center gap-3">
<div class="points-display bg-gradient-primary text-white px-4 py-2 rounded-3 shadow-sm">
<i class="bi bi-coin me-2"></i>
<span>{{ _('Your Points:') }}</span>
<strong class="ms-1">{{ user_points }}</strong>
</div>
</div>
</div>
</div>
{% endfor %}
<div class="row g-4">
{% for item in items %}
<div class="col-lg-4 col-md-6">
<div class="card h-100 shadow-sm border-0 {% if item.id in owned_ids %}owned-bg{% endif %}">
<div class="card-body d-flex flex-column">
<div class="text-center mb-3">
<div class="icon-wrapper text-white rounded-circle mx-auto mb-2 d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
<i class="bi {{ item.icon }} display-4"></i>
</div>
<h5 class="card-title mb-1">{{ item.name }}</h5>
<small class="text-muted">{{ item.description }}</small>
</div>
<div class="mt-auto">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="price-tag">
<span class="h4 text-primary mb-0">{{ item.price }}</span>
<small class="text-muted">{{ _('Points') }}</small>
</div>
{% if item.id in owned_ids %}
<span class="badge bg-success rounded-pill px-3 py-2">
<i class="bi bi-check-lg me-1"></i>{{ _('Owned') }}
</span>
{% endif %}
</div>
{% if item.id in owned_ids %}
<button class="btn btn-outline-secondary w-100" disabled>
<i class="bi bi-bag-check me-2"></i>{{ _('Already Purchased') }}
</button>
{% else %}
{% if user_points >= item.price %}
<form method="post" class="d-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token_value }}">
<input type="hidden" name="item_id" value="{{ item.id }}">
<button type="submit" class="btn btn-primary btn-lg w-100">
<i class="bi bi-cart-plus me-2"></i>{{ _('Buy Now') }}
</button>
</form>
{% else %}
<button class="btn btn-outline-secondary w-100" disabled title="{{ _('Not enough points') }}">
<i class="bi bi-coin me-2"></i>{{ _('Need %(needed)d more points', needed=item.price - user_points) }}
</button>
{% endif %}
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% if not items %}
<div class="text-center py-5">
<i class="bi bi-inbox display-1 text-muted mb-3"></i>
<h4>{{ _('No items available') }}</h4>
<p class="text-muted">{{ _('Check back later for new items in the shop!') }}</p>
</div>
{% endif %}
</div>
<style>
.points-display {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-weight: 600;
}
.icon-wrapper {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.icon-wrapper:hover {
transform: scale(1.05);
}
.card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
border-radius: 12px;
overflow: hidden;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0,0,0,0.15) !important;
}
.owned-bg {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border: 2px solid #28a745 !important;
}
.badge {
font-size: 0.85rem;
}
.price-tag .h4 {
color: #0d6efd;
font-weight: 700;
}
</style>
{% endblock %}