diff --git a/setup.sh b/setup.sh index 7c97135..3bee6a6 100644 --- a/setup.sh +++ b/setup.sh @@ -7,7 +7,7 @@ GREEN='\033[1;32m' YELLOW='\033[1;33m' NC='\033[0m' -# 1. Docker check (incl. Arch Linux) +# Docker check (incl. Arch Linux) if ! command -v docker &>/dev/null; then echo -e "${RED}❗ Docker is not installed.${NC}" read -p "Would you like to install Docker automatically now? [y/N]: " install_docker @@ -23,7 +23,7 @@ if ! command -v docker &>/dev/null; then rm get-docker.sh fi - # Docker group membership prüfen + # Docker group membership check if ! groups | grep -q '\bdocker\b'; then echo -e "${YELLOW}⚠️ Your user is not in the docker group. Adding now...${NC}" sudo usermod -aG docker $USER @@ -37,7 +37,7 @@ if ! command -v docker &>/dev/null; then fi fi -# 2. Check Docker compose (V1 und V2 Plugin, incl. Arch Support) +# Check Docker compose (V1 und V2 Plugin, incl. Arch Support) DOCKER_COMPOSE_CMD="" if command -v docker-compose &>/dev/null; then DOCKER_COMPOSE_CMD="docker-compose" @@ -88,16 +88,7 @@ chmod -R a+rwX "$TRANSLATIONS_DIR" "$DATA_DIR" cd $PROJECT_DIR -## UID/GID-Logic -#if [ "$(id -u)" -eq 0 ]; then -# export UID=1000 -# export GID=1000 -#else -# export UID=$(id -u) -# export GID=$(id -g) -#fi - -# 2. requirements.txt +# requirements.txt cat < requirements.txt flask flask-login @@ -122,7 +113,7 @@ redis EOL -# 3. create .env +# create .env SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_hex(24))') REDEEM_SECRET=$(python3 -c 'import secrets; print(secrets.token_hex(16))') REDEEM_CSRF=$(python3 -c 'import secrets; print(secrets.token_hex(16))') @@ -154,7 +145,7 @@ CHECK_EXPIRING_KEYS_INTERVAL_HOURS=6 ITAD_API_KEY="your-secret-key-here" ITAD_COUNTRY="DE" -# Apprise URLs (separate several with a line break, comma or space) +# Apprise URLs (separate several with a comma or space) APPRISE_URLS="" ### example for multiple notifications @@ -170,86 +161,91 @@ FLASK_DEBUG=1 DEBUGPY=1 EOL -# 4. app.py (the main app) +# app.py (the main app) cat <<'PYTHON_END' > app.py -import os, time +# Standards +import atexit +import csv +import io +import locale +import logging +import os +import random +import re +import secrets +import sqlite3 +import time +import traceback from datetime import datetime, timedelta +from functools import wraps +from io import BytesIO +from time import sleep +from urllib.parse import urlparse from zoneinfo import ZoneInfo + +# 3rd-Provider-Modules import pytz -import warnings -from sqlalchemy.exc import LegacyAPIWarning -warnings.simplefilter("ignore", category=LegacyAPIWarning) +import requests +from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore +from apscheduler.schedulers.background import BackgroundScheduler +from dotenv import load_dotenv from flask import ( Flask, + Markup, + abort, + flash, + g, + jsonify, + make_response, + redirect, render_template, request, - redirect, - url_for, - flash, - session, - abort, send_file, - jsonify, - Markup, - make_response, - g, - abort + session, + url_for +) +from flask_login import ( + LoginManager, + UserMixin, + current_user, + login_required, + login_user, + logout_user ) -from flask_sqlalchemy import SQLAlchemy -from flask_session import Session -from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user -from werkzeug.security import generate_password_hash, check_password_hash -from flask_wtf import CSRFProtect -from flask import abort -from flask import request, redirect -from flask_wtf import FlaskForm -from flask_wtf.csrf import CSRFProtect -from wtforms import StringField, SelectField, TextAreaField, validators -import io -import warnings -import re -import io -import csv -import secrets -import requests -from dotenv import load_dotenv -load_dotenv(override=True) -from sqlalchemy.exc import IntegrityError -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore -import atexit from flask_migrate import Migrate -from sqlalchemy import MetaData, event, UniqueConstraint -from reportlab.pdfgen import canvas -from reportlab.lib.pagesizes import A4, landscape, letter -from reportlab.platypus import ( - SimpleDocTemplate, - Table, - TableStyle, - Paragraph, - Image, - Spacer -) -from reportlab.lib import colors -from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle -from reportlab.lib.utils import ImageReader -from reportlab.lib.units import cm, inch, mm -from io import BytesIO -import reportlab.lib -import traceback -import logging -logging.basicConfig(level=logging.INFO) -# logging.basicConfig(level=logging.INFO) -logging.getLogger('apscheduler').setLevel(logging.WARNING) -from sqlalchemy.engine import Engine -import sqlite3 -from sqlalchemy.orm import joinedload -from functools import wraps from flask_session import Session +from flask_sqlalchemy import SQLAlchemy +from flask_wtf import CSRFProtect, FlaskForm from redis import Redis -from time import sleep -import random -import locale +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4, landscape, letter +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import cm, inch, mm +from reportlab.lib.utils import ImageReader +from reportlab.pdfgen import canvas +from reportlab.platypus import ( + Image, + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle +) +from sqlalchemy import MetaData, UniqueConstraint, event +from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError, LegacyAPIWarning +from sqlalchemy.orm import joinedload +from werkzeug.security import check_password_hash, generate_password_hash +from wtforms import SelectField, StringField, TextAreaField, validators + +# Config +load_dotenv(override=True) +warnings.simplefilter("ignore", category=LegacyAPIWarning) + +# Logging-Config +logging.basicConfig(level=logging.INFO) +logging.getLogger('apscheduler').setLevel(logging.WARNING) + @event.listens_for(Engine, "connect") def enable_foreign_keys(dbapi_connection, connection_record): @@ -262,11 +258,11 @@ TZ = os.getenv('TZ', 'UTC') os.environ['TZ'] = TZ app = Flask(__name__) -# Auf UNIX-Systemen (Linux, Docker) wirksam machen +# UNIX-Systems (Linux, Docker) try: time.tzset() except AttributeError: - pass # tzset gibt es auf Windows nicht + pass # tzset not availabe on Windows local_tz = pytz.timezone(TZ) # Load Languages @@ -326,19 +322,19 @@ convention = { metadata = MetaData(naming_convention=convention) load_dotenv(override=True) -# Lade Umgebungsvariablen aus .env mit override +# load variables from .env with override load_dotenv(override=True) # App-Configuration app.config.update( - # WICHTIGSTE EINSTELLUNGEN + # Most Important SECRET_KEY=os.getenv('SECRET_KEY'), SQLALCHEMY_DATABASE_URI = 'sqlite:////app/data/games.db', SQLALCHEMY_TRACK_MODIFICATIONS = False, DEFAULT_LANGUAGE='en', ITAD_COUNTRY = os.getenv("ITAD_COUNTRY", "DE"), - # SESSION-HANDLING (Produktion: Redis verwenden!) + # SESSION-HANDLING (In Production: Use Redis!) SESSION_TYPE='redis', SESSION_PERMANENT = False, SESSION_USE_SIGNER = True, @@ -350,6 +346,12 @@ app.config.update( SESSION_COOKIE_SAMESITE = 'Lax', PERMANENT_SESSION_LIFETIME = timedelta(days=30), + # LOGIN COOKIE STUFF + REMEMBER_COOKIE_DURATION=timedelta(days=30), + REMEMBER_COOKIE_HTTPONLY=True, + REMEMBER_COOKIE_SECURE=True if os.getenv('FORCE_HTTPS', 'False').lower() == 'true' else False, + REMEMBER_COOKIE_SAMESITE='Lax', + # CSRF-PROTECTION WTF_CSRF_ENABLED = True, WTF_CSRF_SECRET_KEY = os.getenv('CSRF_SECRET_KEY', os.urandom(32).hex()), @@ -367,7 +369,7 @@ Session(app) interval_hours = int(os.getenv('CHECK_EXPIRING_KEYS_INTERVAL_HOURS', 12)) -# Initialisation +# Init db = SQLAlchemy(app, metadata=metadata) migrate = Migrate(app, db) login_manager = LoginManager(app) @@ -377,6 +379,10 @@ login_manager.login_view = 'login' app.logger.addHandler(logging.StreamHandler()) app.logger.setLevel(logging.DEBUG) +@app.errorhandler(403) +def forbidden_error(error): + return render_template('403.html'), 403 + @app.before_request def set_language(): @@ -465,14 +471,13 @@ class Game(db.Model): steam_appid = db.Column(db.String(20)) platform = db.Column(db.String(50), default='pc') current_price = db.Column(db.Float) + current_price_shop = db.Column(db.String(100)) historical_low = db.Column(db.Float) release_date = db.Column(db.DateTime) - metacritic_score = db.Column(db.Integer) release_date = db.Column(db.DateTime) steam_description = db.Column(db.Text) itad_slug = db.Column(db.String(200)) - # with users.id user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) @@ -511,7 +516,6 @@ class RedeemToken(db.Model): now = datetime.now(local_tz) return now > self.expires.astimezone(local_tz) - class GameForm(FlaskForm): name = StringField('Name', [validators.DataRequired()]) steam_key = StringField('Steam Key') @@ -526,7 +530,6 @@ class GameForm(FlaskForm): redeem_date = StringField('Einlösedatum') steam_appid = StringField('Steam App ID') - PLATFORM_CHOICES = [ ('pc', 'PC'), ('xbox', 'XBox'), @@ -541,7 +544,6 @@ STATUS_CHOICES = [ ('geschenkt', 'Geschenkt') ] - with app.app_context(): db.create_all() @@ -596,17 +598,17 @@ def fetch_steam_data(appid): return None def parse_steam_release_date(date_str): - """Parst Steam-Release-Daten im deutschen oder englischen Format.""" + """Parsing Steam-Release-Date (the german us thingy, you know)""" import locale from datetime import datetime - # Versuche deutsches Format + # try german format try: locale.setlocale(locale.LC_TIME, "de_DE.UTF-8") return datetime.strptime(date_str, "%d. %b. %Y") except Exception: pass - # Fallback: Versuche englisches Format + # Fallback: okay lets try the english one try: locale.setlocale(locale.LC_TIME, "en_US.UTF-8") return datetime.strptime(date_str, "%d %b, %Y") @@ -631,7 +633,6 @@ def fetch_itad_slug(steam_appid: int) -> str | None: return None - def fetch_itad_game_id(steam_appid: int) -> str | None: api_key = os.getenv("ITAD_API_KEY") if not api_key: @@ -707,21 +708,37 @@ def set_lang(lang): @app.route('/set-theme/') def set_theme(theme): resp = make_response('', 204) - resp.set_cookie('theme', theme, max_age=60*60*24*365) # 1 Jahr Gültigkeit + resp.set_cookie('theme', theme, max_age=60*60*24*365) return resp @app.route('/login', methods=['GET', 'POST']) def login(): + if current_user.is_authenticated: # Prevent already logged-in users from accessing login page + return redirect(url_for('index')) + if request.method == 'POST': - username = request.form['username'] - password = request.form['password'] + username = request.form.get('username') + password = request.form.get('password') + remember = request.form.get('remember_me') == 'true' + user = User.query.filter_by(username=username).first() - + if user and check_password_hash(user.password, password): - login_user(user) - return redirect(url_for('index')) - - flash(translate('Invalid credentials', session.get('lang', 'en')), 'danger') + # Pass remember=True to login_user and set duration + # The duration will be taken from app.config['REMEMBER_COOKIE_DURATION'] + login_user(user, remember=remember) + + # Log activity + log_activity(user.id, 'user_login', f"User '{user.username}' logged in.") + + next_page = request.args.get('next') + # Add security check for next_page to prevent open redirect + if not next_page or urlparse(next_page).netloc != '': + next_page = url_for('index') + flash(translate('Logged in successfully.'), 'success') + return redirect(next_page) + else: + flash(translate('Invalid username or password.'), 'danger') return render_template('login.html') @app.route('/register', methods=['GET', 'POST']) @@ -1222,11 +1239,11 @@ def admin_audit_logs(): def update_game_data(game_id): game = Game.query.get_or_404(game_id) - # 1. Steam AppID aus dem Formular holen + # 1. Getting Steam AppID steam_appid = request.form.get('steam_appid', '').strip() app.logger.info(f"🚀 Update gestartet für Game {game_id} mit AppID: {steam_appid}") - # 2. Steam-Daten abrufen + # 2. Steam-Data steam_data = None if steam_appid: try: @@ -1256,12 +1273,12 @@ def update_game_data(game_id): app.logger.error(f"💥 Kritischer Steam-Fehler: {str(e)}", exc_info=True) flash(translate('Fehler bei Steam-Abfrage'), 'danger') - # ITAD-Slug abrufen und speichern + # ITAD-Slug donings and such itad_slug = fetch_itad_slug(steam_appid) if itad_slug: game.itad_slug = itad_slug - # 4. ITAD-Preisdaten + # 4. ITAD-Prices price_data = None if steam_appid: try: @@ -1273,20 +1290,24 @@ def update_game_data(game_id): price_data = fetch_itad_prices(game.itad_game_id) if price_data: - # Aktueller Steam-Preis - steam_deal = next( - (deal for deal in price_data.get("deals", []) - if deal.get("shop", {}).get("name", "").lower() == "steam"), - None - ) + # Best price right now + all_deals = price_data.get("deals", []) + if all_deals: + best_deal = min( + all_deals, + key=lambda deal: deal.get("price", {}).get("amount", float('inf')) + ) + game.current_price = best_deal.get("price", {}).get("amount") + game.current_price_shop = best_deal.get("shop", {}).get("name") + app.logger.info(f"💶 Current Best: {game.current_price}€ at {game.current_price_shop}") + else: + game.current_price = None + game.current_price_shop = None + + app.logger.info(f"💶 Current Best: {game.current_price}€") - if steam_deal: - game.current_price = steam_deal.get("price", {}).get("amount") - app.logger.info(f"💶 Aktueller Preis: {game.current_price}€") - - # Historisches Minimum game.historical_low = price_data.get("historyLow", {}).get("all", {}).get("amount") - app.logger.info(f"📉 Historisches Minimum: {game.historical_low}€") + app.logger.info(f"📉 Historical Low: {game.historical_low}€") else: app.logger.warning("⚠️ Keine ITAD-Preisdaten erhalten") else: @@ -1296,16 +1317,6 @@ def update_game_data(game_id): app.logger.error(f"💥 ITAD-API-Fehler: {str(e)}", exc_info=True) flash(translate('Fehler bei Preisabfrage'), 'danger') - # 5. Metacritic-Score (Beispielimplementierung) - try: - if game.name: - app.logger.info(f"🎮 Starte Metacritic-Abfrage für: {game.name}") - # Hier echte API-Integration einfügen - game.metacritic_score = random.randint(50, 100) # Mock-Daten - except Exception as e: - app.logger.error(f"💥 Metacritic-Fehler: {str(e)}") - - # 6. Datenbank-Update try: db.session.commit() flash(translate('Externe Daten erfolgreich aktualisiert!'), 'success') @@ -1608,7 +1619,6 @@ cat <<'HTML_END' > templates/index.html {{ _('Redeem by') }} {{ _('Shop') }} {{ _('Price') }} - {{ _('Metascore') }} {{ _('Actions') }} @@ -1616,22 +1626,24 @@ cat <<'HTML_END' > templates/index.html {% for game in games %} - {% if game.steam_appid %} - Steam Header - {% elif game.url and 'gog.com' in game.url %} - GOG Logo - {% endif %} + + {% if game.steam_appid %} + Steam Header + {% elif game.url and 'gog.com' in game.url %} + GOG Logo + {% endif %} + {{ game.name }} {{ game.steam_key }} @@ -1656,27 +1668,31 @@ cat <<'HTML_END' > templates/index.html {% endif %} - {% if game.current_price %} -
- {{ _('Now') }} - {{ "%.2f"|format(game.current_price) }} € + {% if game.current_price is not none %} +
+
+ {{ _('Current Deal') }}
-
- {% endif %} - {% if game.historical_low %} -
- {{ _('Hist. Low') }} - {{ "%.2f"|format(game.historical_low) }} € +
+ {{ "%.2f"|format(game.current_price) }} € + {% if game.current_price_shop %} + ({{ game.current_price_shop }}) + {% endif %}
-
+
+ {% endif %} + + {# Historical Low #} + {% if game.historical_low is not none %} +
+
+ {{ _('Hist. Low') }} +
+
+ {{ "%.2f"|format(game.historical_low) }} € +
+
{% endif %} - - - {% if game.metacritic_score %} - - {{ game.metacritic_score }} - - {% endif %} {% if game.status == 'geschenkt' %} @@ -2358,19 +2374,6 @@ cat < templates/game_details.html
{{ _('Current Price') }}
{{ "%.2f €"|format(game.current_price) if game.current_price else 'N/A' }}
-
{{ _('Metascore') }}
-
- {% if game.metacritic_score %} - - {{ game.metacritic_score }} - - {% else %} - N/A - {% endif %} -
@@ -2378,8 +2381,6 @@ cat < templates/game_details.html - - {% if game.steam_description %}
@@ -2704,13 +2705,29 @@ body { align-items: flex-start !important; } } + +.card-body img, +.steam-description img { + max-width: 100%; + height: auto; + display: block; + margin: 8px auto; +} + +td.font-monospace { + word-break: break-all; + /* or */ + overflow-wrap: break-word; +} + .alert-error { background-color: #f8d7da; border-color: #f5c6cb; color: #721c24; } .alert-success { background-color: #d4edda; border-color: #c3e6cb; color: #155724; } .alert-info { background: #d9edf7; color: #31708f; } + CSS_END -# 7. directories and permissions +# directories and permissions mkdir -p ../data chmod -R a+rwX ../data find ../data -type d -exec chmod 775 {} \; @@ -2720,11 +2737,11 @@ find ../data -type f -exec chmod 664 {} \; cat < entrypoint.sh #!/bin/bash -# Debug-Ausgaben hinzufügen +# Debug-Output echo "🔄 DEBUGPY-Value: '$DEBUGPY'" echo "🔄 FLASK_DEBUG-Value: '$FLASK_DEBUG'" -# Debug-Modus aktivieren, wenn eine der Variablen gesetzt ist +# Debug-Modus activate if .env told you so if [[ "$DEBUGPY" == "1" || "$FLASK_DEBUG" == "1" ]]; then echo "🔄 Starting in DEBUG mode (Port 5678)..." exec python -m debugpy --listen 0.0.0.0:5678 -m flask run --host=0.0.0.0 --port=5000 @@ -2842,7 +2859,7 @@ ENTRYPOINT ["/app/entrypoint.sh"] DOCKER_END -# 6. docker-compose.yml +# create docker-compose.yml cat < docker-compose.yml services: redis: