diff --git a/.env_example b/.env_example new file mode 100644 index 0000000..1cea2d2 --- /dev/null +++ b/.env_example @@ -0,0 +1,3 @@ +# You can change this file, to your preferred Settings. + +PORT=YOUR PORT \ No newline at end of file diff --git a/.gitignore b/.gitignore index 310dbc5..aed461a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index ba30676..1e98026 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,21 @@ MiniFacebook is a minimalist social network built with [Flask](https://flask.pal pip install -r requirments.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 diff --git a/main.py b/main.py index ea29986..ae79004 100644 --- a/main.py +++ b/main.py @@ -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,9 +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) - print("Serving connections from port 80....") + 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) \ No newline at end of file diff --git a/models.py b/models.py index 00b114e..64b02c6 100644 --- a/models.py +++ b/models.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 00a7717..66c4891 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ waitress authlib sqlalchemy requests +dotenv \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css index 9dc4872..41a1872 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -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) { diff --git a/templates/base.html b/templates/base.html index 1dd3db3..8266545 100644 --- a/templates/base.html +++ b/templates/base.html @@ -24,7 +24,7 @@
-