GameKeyManager/setup.sh

3101 lines
104 KiB
Bash
Raw Normal View History

2025-04-21 10:45:06 +00:00
#!/bin/bash
set -e
2025-04-29 16:02:22 +00:00
# Colors
2025-04-26 12:32:07 +00:00
RED='\033[1;31m'
GREEN='\033[1;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Docker check (incl. Arch Linux)
2025-04-26 12:32:07 +00:00
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
if [[ "$install_docker" =~ ^[YyJj]$ ]]; then
if [ -f /etc/arch-release ]; then
echo -e "${GREEN}▶️ Installing Docker for Arch Linux...${NC}"
sudo pacman -Sy --noconfirm docker
sudo systemctl enable --now docker.service
else
echo -e "${GREEN}▶️ Using generic Docker installation script...${NC}"
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
rm get-docker.sh
fi
# Docker group membership check
2025-04-26 12:32:07 +00:00
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
newgrp docker
fi
echo -e "${GREEN}✔️ Docker has been installed.${NC}"
else
echo -e "${YELLOW}❌ Docker is required. Exiting script.${NC}"
exit 1
fi
fi
# Check Docker compose (V1 und V2 Plugin, incl. Arch Support)
2025-04-26 12:32:07 +00:00
DOCKER_COMPOSE_CMD=""
if command -v docker-compose &>/dev/null; then
DOCKER_COMPOSE_CMD="docker-compose"
elif docker compose version &>/dev/null; then
DOCKER_COMPOSE_CMD="docker compose"
else
echo -e "${RED}❗ Neither docker-compose nor docker compose plugin is installed!${NC}"
read -p "Would you like to install Docker Compose automatically now? [y/N]: " install_compose
if [[ "$install_compose" =~ ^[YyJj]$ ]]; then
if [ -f /etc/arch-release ]; then
sudo pacman -Sy --noconfirm docker-compose
elif command -v apt-get &>/dev/null; then
sudo apt-get update
sudo apt-get install -y docker-compose-plugin
2025-04-29 12:13:27 +00:00
sudo apt-get install -y docker-compose
2025-04-26 12:32:07 +00:00
elif command -v dnf &>/dev/null; then
sudo dnf install -y docker-compose
elif command -v yum &>/dev/null; then
sudo yum install -y docker-compose-plugin
else
echo -e "${RED}❌ Unsupported package manager! Please install Docker Compose manually.${NC}"
exit 1
fi
# Final check
if ! command -v docker-compose &>/dev/null && ! docker compose version &>/dev/null; then
echo -e "${RED}❌ Docker Compose installation failed!${NC}"
exit 1
fi
echo -e "${GREEN}✔️ Docker Compose has been installed successfully.${NC}"
else
echo -e "${YELLOW}❌ Docker Compose is required. Exiting script.${NC}"
exit 1
fi
fi
2025-04-29 12:46:55 +00:00
# Configuration
2025-04-21 10:45:06 +00:00
PROJECT_DIR="steam-gift-manager"
TRANSLATIONS_DIR="$PWD/$PROJECT_DIR/translations"
2025-04-29 16:02:22 +00:00
DATA_DIR="$PWD/data"
2025-04-21 10:45:06 +00:00
# Create folders
mkdir -p "$PROJECT_DIR"
mkdir -p "$PROJECT_DIR"/{templates,static,translations}
2025-04-26 12:32:07 +00:00
mkdir -p "$DATA_DIR"
chmod -R a+rwX "$TRANSLATIONS_DIR" "$DATA_DIR"
echo -e "\n\033[1;32m✅ Downloading assets - Please wait!\033[0m"
# Download Pictures from my server
cd "$PROJECT_DIR/static"
2025-05-09 15:06:26 +00:00
wget -O logo.webp "https://assets.skynet.li/logo.webp" > /dev/null 2>&1
wget -O logo_small.webp "https://assets.skynet.li/logo_small.webp" > /dev/null 2>&1
wget -O forgejo.webp "https://assets.skynet.li/forgejo.webp" > /dev/null 2>&1
wget -O gog_logo.webp "https://assets.skynet.li/gog_logo.webp" > /dev/null 2>&1
wget -O logo_small_maskable.webp "https://assets.skynet.li/logo_small_maskable.webp" > /dev/null 2>&1
wget -O favicon.ico "https://assets.skynet.li/favicon.ico" > /dev/null 2>&1
wget -O apple-touch-icon.png "https://assets.skynet.li/apple-touch-icon.png" > /dev/null 2>&1
wget -O web-app-manifest-192x192.png "https://assets.skynet.li/web-app-manifest-192x192.png" > /dev/null 2>&1
wget -O web-app-manifest-512x512.png "https://assets.skynet.li/web-app-manifest-512x512.png" > /dev/null 2>&1
cd ../..
2025-04-21 10:45:06 +00:00
cd $PROJECT_DIR
# requirements.txt
2025-04-21 10:45:06 +00:00
cat <<EOL > requirements.txt
flask
flask-login
2025-04-26 12:32:07 +00:00
flask-wtf
flask-migrate
2025-04-21 10:45:06 +00:00
werkzeug
python-dotenv
flask-sqlalchemy
jinja2<3.1.0
2025-04-26 12:32:07 +00:00
itsdangerous
sqlalchemy
apscheduler
reportlab
requests
pillow
2025-04-29 12:27:55 +00:00
gunicorn
apprise
debugpy
pytz
Flask-Session
redis
2025-04-26 12:32:07 +00:00
EOL
# create .env
2025-04-26 12:32:07 +00:00
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))')
cat <<EOL > .env
# Flask-Configuration
SECRET_KEY="$SECRET_KEY"
REDEEM_SECRET="$REDEEM_SECRET"
WTF_CSRF_SECRET_KEY="$REDEEM_CSRF"
# Language Settings
DEFAULT_LANGUAGE="en"
SUPPORTED_LANGUAGES="de,en"
2025-04-26 12:32:07 +00:00
# Timezone
TZ=Europe/Berlin
# Security
2025-05-02 12:52:57 +00:00
FORCE_HTTPS=False
SESSION_COOKIE_SECURE=auto
2025-04-26 12:32:07 +00:00
CSRF_ENABLED="True"
2025-04-26 12:32:07 +00:00
# Account registration
REGISTRATION_ENABLED="True"
# checking interval if keys have to be redeemed before a specific date
CHECK_EXPIRING_KEYS_INTERVAL_HOURS=6
# Want to check prices? Here you are!
ITAD_API_KEY="your-secret-key-here"
ITAD_COUNTRY="DE"
# Apprise URLs (separate several with a comma or space)
APPRISE_URLS=""
2025-04-26 12:32:07 +00:00
### example for multiple notifications
#APPRISE_URLS="pover://USER_KEY@APP_TOKEN
#gotify://gotify.example.com/TOKEN
#matrixs://TOKEN@matrix.org/!ROOM_ID"
# Redis URL
REDIS_URL=redis://redis:6379/0
# Enable Debug (e.g. for VS Code)
FLASK_DEBUG=1
DEBUGPY=0
2025-04-21 10:45:06 +00:00
EOL
# app.py (the main app)
2025-04-21 10:45:06 +00:00
cat <<'PYTHON_END' > app.py
2025-05-08 13:31:53 +00:00
# Standard library imports
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
2025-05-08 13:31:53 +00:00
import warnings
# 3rd-Provider-Modules
import pytz
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,
send_file,
session,
url_for
)
from flask_login import (
LoginManager,
UserMixin,
current_user,
login_required,
login_user,
logout_user
)
2025-04-26 12:32:07 +00:00
from flask_migrate import Migrate
from flask_session import Session
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect, FlaskForm
from redis import Redis
from reportlab.lib import colors
2025-04-26 12:32:07 +00:00
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
2025-04-26 12:32:07 +00:00
from reportlab.platypus import (
Image,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle
2025-04-26 12:32:07 +00:00
)
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):
if isinstance(dbapi_connection, sqlite3.Connection):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON;")
cursor.close()
2025-05-08 13:31:53 +00:00
ITAD_API_KEY_PLACEHOLDER = "your_api_key_here"
TZ = os.getenv('TZ', 'UTC')
os.environ['TZ'] = TZ
2025-04-21 10:45:06 +00:00
app = Flask(__name__)
2025-05-09 09:31:50 +00:00
app.jinja_env.globals['getattr'] = getattr
2025-04-29 16:02:22 +00:00
@app.errorhandler(404)
def not_found_error(error):
return render_template('404.html'), 404
# UNIX-Systems (Linux, Docker)
try:
time.tzset()
except AttributeError:
pass # tzset not availabe on Windows
local_tz = pytz.timezone(TZ)
# Load Languages
2025-05-02 15:25:12 +00:00
import os
2025-05-02 14:13:34 +00:00
import json
TRANSLATION_DIR = os.path.join(os.getcwd(), 'translations')
2025-05-02 15:25:12 +00:00
SUPPORTED_LANGUAGES = ['de', 'en']
TRANSLATIONS = {}
2025-05-02 14:13:34 +00:00
for lang in SUPPORTED_LANGUAGES:
try:
2025-05-02 15:25:12 +00:00
with open(os.path.join(TRANSLATION_DIR, f'{lang}.json'), encoding='utf-8') as f:
TRANSLATIONS[lang] = json.load(f)
print(f"✅ Loaded {lang} translations")
2025-05-02 14:13:34 +00:00
except Exception:
print(f"❌ Failed loading {lang}.json: {str(e)}")
TRANSLATIONS[lang] = {}
2025-05-02 14:13:34 +00:00
def translate(key, lang=None, **kwargs):
lang = lang or session.get('lang', 'en')
fallback_lang = app.config.get('DEFAULT_LANGUAGE', 'en')
2025-05-03 10:26:54 +00:00
translations = TRANSLATIONS.get(lang, {})
fallback_translations = TRANSLATIONS.get(fallback_lang, {})
2025-05-03 10:26:54 +00:00
value = translations.get(key) or fallback_translations.get(key) or key
return value.format(**kwargs) if isinstance(value, str) else value
## DEBUG Translations
if app.debug:
print(f"Loaded translations for 'de': {TRANSLATIONS.get('de', {})}")
### Admin decorator
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
abort(403)
if not current_user.is_admin:
abort(403)
return f(*args, **kwargs)
return decorated_function
2025-04-26 12:32:07 +00:00
csrf = CSRFProtect(app)
convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}
metadata = MetaData(naming_convention=convention)
load_dotenv(override=True)
# load variables from .env with override
2025-04-26 12:32:07 +00:00
load_dotenv(override=True)
2025-04-29 12:46:55 +00:00
# App-Configuration
2025-04-26 12:32:07 +00:00
app.config.update(
# Most Important
2025-04-26 12:32:07 +00:00
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 (In Production: Use Redis!)
SESSION_TYPE='redis',
SESSION_PERMANENT = False,
SESSION_USE_SIGNER = True,
SESSION_REDIS=Redis.from_url(os.getenv("REDIS_URL", "redis://redis:6379/0")),
SESSION_FILE_DIR = '/app/data/flask-sessions',
SESSION_COOKIE_NAME = 'gamekeys_session',
SESSION_COOKIE_SECURE = os.getenv('SESSION_COOKIE_SECURE', 'False').lower() == 'true',
SESSION_COOKIE_HTTPONLY = True,
SESSION_COOKIE_SAMESITE = 'Lax',
PERMANENT_SESSION_LIFETIME = timedelta(days=30),
2025-05-08 13:31:53 +00:00
# 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()),
WTF_CSRF_TIME_LIMIT = 3600,
# SECURITYsa & PERFORMANCE
REGISTRATION_ENABLED = os.getenv('REGISTRATION_ENABLED', 'True').lower() == 'true',
SEND_FILE_MAX_AGE_DEFAULT = int(os.getenv('SEND_FILE_MAX_AGE_DEFAULT', 0)),
TEMPLATES_AUTO_RELOAD = os.getenv('TEMPLATES_AUTO_RELOAD', 'True').lower() == 'true',
PREFERRED_URL_SCHEME = 'https' if os.getenv('FORCE_HTTPS') else 'http'
2025-04-26 12:32:07 +00:00
)
Session(app)
2025-04-26 12:32:07 +00:00
interval_hours = int(os.getenv('CHECK_EXPIRING_KEYS_INTERVAL_HOURS', 12))
# Init
2025-04-26 12:32:07 +00:00
db = SQLAlchemy(app, metadata=metadata)
migrate = Migrate(app, db)
2025-04-21 10:45:06 +00:00
login_manager = LoginManager(app)
login_manager.login_view = 'login'
2025-04-26 12:32:07 +00:00
# Logging
app.logger.addHandler(logging.StreamHandler())
app.logger.setLevel(logging.DEBUG)
2025-04-26 12:32:07 +00:00
@app.errorhandler(403)
def forbidden_error(error):
return render_template('403.html'), 403
2025-04-21 10:45:06 +00:00
2025-04-29 16:02:22 +00:00
@app.before_request
def set_language():
if 'lang' not in session or not session['lang']:
session['lang'] = app.config.get('DEFAULT_LANGUAGE', 'en')
g.lang = session['lang']
2025-05-02 12:52:57 +00:00
def enforce_https():
if os.getenv('FORCE_HTTPS', 'False').lower() == 'true' and not app.debug:
proto = request.headers.get('X-Forwarded-Proto', 'http')
if proto != 'https' and not request.is_secure:
2025-05-02 12:52:57 +00:00
url = request.url.replace('http://', 'https://', 1)
return redirect(url, code=301)
def debug_translations():
if app.debug:
app.logger.debug(f"Lang: {session.get('lang')}")
app.before_request(enforce_https)
2025-05-02 12:52:57 +00:00
2025-04-21 10:45:06 +00:00
@app.context_processor
def inject_template_globals():
return {
'_': lambda key, **kwargs: translate(key, lang=session.get('lang', 'en'), **kwargs),
'now': datetime.now(local_tz),
'app_version': os.getenv('APP_VERSION', '1.0.0'),
'local_tz': local_tz
}
@app.template_filter('strftime')
def _jinja2_filter_datetime(date, fmt='%d.%m.%Y'):
if date is None:
return ''
return date.strftime(fmt)
@app.errorhandler(403)
def forbidden(e):
return render_template('403.html'), 403
2025-04-21 10:45:06 +00:00
2025-04-29 12:46:55 +00:00
# DB Models
class ActivityLog(db.Model):
__tablename__ = 'activity_logs'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
action = db.Column(db.String(100), nullable=False)
details = db.Column(db.Text)
timestamp = db.Column(db.DateTime, default=lambda: datetime.now(local_tz))
user = db.relationship('User', backref='activities')
class User(UserMixin, db.Model):
__tablename__ = 'users'
2025-04-21 10:45:06 +00:00
id = db.Column(db.Integer, primary_key=True)
2025-04-26 12:32:07 +00:00
username = db.Column(db.String(80), unique=True, nullable=False)
password = db.Column(db.String(256), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
games = db.relationship(
'Game',
back_populates='owner',
cascade='all, delete-orphan',
passive_deletes=True
)
2025-04-21 10:45:06 +00:00
class Game(db.Model):
__tablename__ = 'games'
2025-05-04 13:03:02 +00:00
__table_args__ = (
UniqueConstraint('steam_key', 'user_id', name='uq_steam_key_user'),
)
2025-04-21 10:45:06 +00:00
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
2025-04-26 12:32:07 +00:00
steam_key = db.Column(db.String(100), nullable=False, unique=True)
2025-04-21 10:45:06 +00:00
status = db.Column(db.String(50), nullable=False)
recipient = db.Column(db.String(100))
notes = db.Column(db.Text)
url = db.Column(db.String(200))
created_at = db.Column(db.DateTime, default=lambda: datetime.now(local_tz))
2025-04-21 10:45:06 +00:00
redeem_date = db.Column(db.DateTime)
2025-04-21 11:34:11 +00:00
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)
release_date = db.Column(db.DateTime)
itad_slug = db.Column(db.String(200))
steam_description_en = db.Column(db.Text)
steam_description_de = db.Column(db.Text)
# with users.id
2025-05-04 13:03:02 +00:00
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
owner = db.relationship(
'User',
back_populates='games'
)
redeem_tokens = db.relationship(
'RedeemToken',
back_populates='game',
cascade='all, delete-orphan',
passive_deletes=True
)
2025-04-21 10:45:06 +00:00
2025-04-26 12:32:07 +00:00
class RedeemToken(db.Model):
__tablename__ = 'redeem_tokens'
2025-04-26 12:32:07 +00:00
id = db.Column(db.Integer, primary_key=True)
token = db.Column(db.String(17), unique=True, nullable=False)
expires = db.Column(db.DateTime(timezone=True), nullable=False)
2025-04-26 12:32:07 +00:00
total_hours = db.Column(db.Integer, nullable=False)
# ForeignKey with CASCADE
game_id = db.Column(
db.Integer,
db.ForeignKey('games.id', ondelete='CASCADE'),
nullable=False
)
game = db.relationship('Game', back_populates='redeem_tokens')
def is_expired(self):
# use timeszone (from .env)
local_tz = pytz.timezone(os.getenv('TZ', 'UTC'))
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')
status = SelectField('Status', choices=[
('nicht eingelöst', 'Nicht eingelöst'),
('eingelöst', 'Eingelöst'),
('geschenkt', 'Geschenkt')
])
recipient = StringField('Empfänger')
notes = TextAreaField('Notizen')
url = StringField('Store URL')
redeem_date = StringField('Einlösedatum')
steam_appid = StringField('Steam App ID')
PLATFORM_CHOICES = [
2025-05-09 09:31:50 +00:00
('steam', 'Steam'),
('gog', 'GOG'),
('xbox', 'XBox'),
('playstation', 'PlayStation'),
('switch', 'Nintendo Switch'),
2025-05-09 09:31:50 +00:00
('other', 'Other'),
('pc', 'PC')
]
STATUS_CHOICES = [
('nicht eingelöst', 'Nicht eingelöst'),
('eingelöst', 'Eingelöst'),
('geschenkt', 'Geschenkt')
]
2025-04-26 12:32:07 +00:00
with app.app_context():
db.create_all()
2025-04-21 10:45:06 +00:00
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
2025-04-26 12:32:07 +00:00
def extract_steam_appid(url):
match = re.search(r'store\.steampowered\.com/app/(\d+)', url or '')
return match.group(1) if match else ''
# 404
def get_or_404(model, id):
instance = db.session.get(model, id)
if not instance:
abort(404)
return instance
# Admin Audit Helper
def log_activity(user_id, action, details=None):
"""
Store an activity log entry for auditing purposes.
"""
log = ActivityLog(
user_id=user_id,
action=action,
details=details
)
db.session.add(log)
db.session.commit()
# Game Infos Helper
def fetch_steam_data(appid, lang='en'):
lang_map = {
'en': 'english',
'de': 'german'
}
steam_lang = lang_map.get(lang, 'english')
try:
response = requests.get(
"https://store.steampowered.com/api/appdetails",
params={"appids": appid, "l": steam_lang},
timeout=15
)
data = response.json().get(str(appid), {})
if data.get("success"):
return {
"name": data["data"].get("name"),
"detailed_description": data["data"].get("detailed_description"),
"release_date": data["data"].get("release_date", {}).get("date"),
}
except Exception as e:
app.logger.error(f"Steam API error: {str(e)}")
return None
def parse_steam_release_date(date_str):
"""Parsing Steam-Release-Date (the german us thingy, you know)"""
import locale
from datetime import datetime
# 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: okay lets try the english one
try:
locale.setlocale(locale.LC_TIME, "en_US.UTF-8")
return datetime.strptime(date_str, "%d %b, %Y")
except Exception:
pass
return None
def fetch_itad_slug(steam_appid: int) -> str | None:
api_key = os.getenv("ITAD_API_KEY")
2025-05-08 13:31:53 +00:00
if not api_key or api_key.strip() == "your-secret-key-here":
app.logger.warning("ITAD-API-Key ist nicht gesetzt oder ist ein Platzhalter.")
return None
try:
response = requests.get(
"https://api.isthereanydeal.com/games/lookup/v1",
params={"key": api_key, "appid": steam_appid, "platform": "steam"},
timeout=10
)
data = response.json()
return data.get("game", {}).get("slug")
except Exception as e:
app.logger.error(f"ITAD Error: {str(e)}")
return None
def fetch_itad_game_id(steam_appid: int) -> str | None:
api_key = os.getenv("ITAD_API_KEY")
2025-05-08 13:31:53 +00:00
if not api_key or api_key.strip() == "your-secret-key-here":
app.logger.warning("ITAD-API-Key ist nicht gesetzt oder ist ein Platzhalter.")
return None
try:
response = requests.get(
"https://api.isthereanydeal.com/games/lookup/v1",
params={"key": api_key, "appid": steam_appid, "platform": "steam"},
timeout=10
)
response.raise_for_status()
data = response.json()
if data.get("found") and data.get("game") and data["game"].get("id"):
return data["game"]["id"]
app.logger.error(f"ITAD Response Error: {data}")
return None
except Exception as e:
app.logger.error(f"ITAD Error: {str(e)}")
return None
def fetch_itad_prices(game_id: str) -> dict | None:
api_key = os.getenv("ITAD_API_KEY")
country = os.getenv("ITAD_COUNTRY", "DE")
2025-05-08 13:31:53 +00:00
if not api_key or api_key.strip() == "your-secret-key-here":
app.logger.warning("ITAD-API-Key ist nicht gesetzt oder ist ein Platzhalter.")
return None
try:
response = requests.post(
"https://api.isthereanydeal.com/games/prices/v3",
params={
"key": api_key,
"country": country,
"shops": "steam",
"vouchers": "false"
},
json=[game_id],
headers={"Content-Type": "application/json"},
timeout=15
)
response.raise_for_status()
return response.json()[0]
2025-05-08 13:31:53 +00:00
except Exception as e:
app.logger.error(f"ITAD-Preisabfrage fehlgeschlagen: {str(e)}")
return None
2025-04-21 10:45:06 +00:00
@app.route('/')
@login_required
def index():
search_query = request.args.get('q', '')
2025-04-26 12:32:07 +00:00
query = Game.query.filter_by(user_id=current_user.id)
2025-04-21 10:45:06 +00:00
if search_query:
query = query.filter(Game.name.ilike(f'%{search_query}%'))
2025-04-26 12:32:07 +00:00
2025-04-21 10:45:06 +00:00
games = query.order_by(Game.created_at.desc()).all()
return render_template('index.html',
games=games,
format_date=lambda dt: dt.strftime('%d.%m.%Y') if dt else '',
search_query=search_query)
@app.route('/set-lang/<lang>')
def set_lang(lang):
2025-05-02 14:13:34 +00:00
if lang in SUPPORTED_LANGUAGES:
2025-04-21 10:45:06 +00:00
session['lang'] = lang
2025-05-03 10:26:54 +00:00
session.permanent = True
2025-05-02 14:13:34 +00:00
return redirect(request.referrer or url_for('index'))
2025-04-21 10:45:06 +00:00
@app.route('/set-theme/<theme>')
def set_theme(theme):
resp = make_response('', 204)
resp.set_cookie('theme', theme, max_age=60*60*24*365)
2025-04-21 10:45:06 +00:00
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'))
2025-04-21 10:45:06 +00:00
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
remember = request.form.get('remember_me') == 'true'
2025-04-21 10:45:06 +00:00
user = User.query.filter_by(username=username).first()
2025-04-21 10:45:06 +00:00
if user and check_password_hash(user.password, password):
# 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')
2025-04-21 10:45:06 +00:00
return render_template('login.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
2025-04-26 12:32:07 +00:00
if not app.config['REGISTRATION_ENABLED']:
abort(403)
2025-04-21 10:45:06 +00:00
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
existing_user = User.query.filter_by(username=username).first()
if existing_user:
flash(translate('Username already exists'), 'error')
2025-04-21 10:45:06 +00:00
return redirect(url_for('register'))
# make the first user admin
is_admin = User.query.count() == 0
new_user = User(
username=username,
password=generate_password_hash(password),
is_admin=is_admin
)
2025-04-21 10:45:06 +00:00
db.session.add(new_user)
db.session.commit()
login_user(new_user)
flash(translate('Registration successful'), 'success')
2025-04-21 10:45:06 +00:00
return redirect(url_for('index'))
2025-04-21 10:45:06 +00:00
return render_template('register.html')
@app.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('login'))
2025-04-26 12:32:07 +00:00
@app.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
if request.method == 'POST':
current_password = request.form['current_password']
new_password = request.form['new_password']
confirm_password = request.form['confirm_password']
2025-04-21 11:06:38 +00:00
2025-04-26 12:32:07 +00:00
if not check_password_hash(current_user.password, current_password):
flash(translate('Current passwort is wrong'), 'danger')
2025-04-26 12:32:07 +00:00
return redirect(url_for('change_password'))
if new_password != confirm_password:
flash(translate('New Passwords are not matching'), 'danger')
2025-04-26 12:32:07 +00:00
return redirect(url_for('change_password'))
current_user.password = generate_password_hash(new_password)
db.session.commit()
2025-05-03 10:26:54 +00:00
flash(translate('Password changed successfully', session.get('lang', 'en')), 'success')
2025-04-26 12:32:07 +00:00
return redirect(url_for('index'))
return render_template('change_password.html')
2025-04-21 11:06:38 +00:00
2025-04-21 10:45:06 +00:00
@app.route('/add', methods=['GET', 'POST'])
@login_required
def add_game():
if request.method == 'POST':
try:
2025-04-21 11:06:38 +00:00
url = request.form.get('url', '')
steam_appid = request.form.get('steam_appid', '').strip()
2025-04-26 12:32:07 +00:00
2025-04-21 11:06:38 +00:00
if not steam_appid:
steam_appid = extract_steam_appid(url)
steam_key = request.form['steam_key']
if Game.query.filter_by(steam_key=steam_key).first():
flash(translate('Steam Key already exists!'), 'error')
return redirect(url_for('add_game'))
2025-04-21 10:45:06 +00:00
new_game = Game(
name=request.form['name'],
steam_key=steam_key,
2025-04-21 10:45:06 +00:00
status=request.form['status'],
recipient=request.form.get('recipient', ''),
notes=request.form.get('notes', ''),
2025-04-21 11:06:38 +00:00
url=url,
2025-04-22 11:20:15 +00:00
steam_appid=steam_appid,
2025-04-21 10:45:06 +00:00
redeem_date=datetime.strptime(request.form['redeem_date'], '%Y-%m-%d') if request.form['redeem_date'] else None,
user_id=current_user.id
)
2025-04-26 12:32:07 +00:00
2025-04-21 10:45:06 +00:00
db.session.add(new_game)
db.session.commit()
flash(translate('Game added successfully!'), 'success')
2025-04-21 10:45:06 +00:00
return redirect(url_for('index'))
2025-04-26 12:32:07 +00:00
except IntegrityError as e:
2025-04-26 12:32:07 +00:00
db.session.rollback()
if "UNIQUE constraint failed: game.steam_key" in str(e):
flash(translate('Steam Key already exists!'), 'error')
else:
flash(translate('Database error: %(error)s', error=str(e)), 'error')
2025-04-21 10:45:06 +00:00
except Exception as e:
db.session.rollback()
flash(translate('Error: %(error)s', error=str(e)), 'error')
2025-04-26 12:32:07 +00:00
return render_template(
'add_game.html',
platforms=PLATFORM_CHOICES,
statuses=STATUS_CHOICES
)
2025-04-21 10:45:06 +00:00
@app.route('/edit/<int:game_id>', methods=['GET', 'POST'])
@login_required
def edit_game(game_id):
# Eager Loading für Tokens
game = Game.query.options(joinedload(Game.redeem_tokens)).get_or_404(game_id)
def safe_parse_date(date_str):
try:
naive = datetime.strptime(date_str, '%Y-%m-%d') if date_str else None
return local_tz.localize(naive) if naive else None
except ValueError:
return None
2025-04-22 11:20:15 +00:00
2025-04-21 10:45:06 +00:00
if request.method == 'POST':
try:
# Validation
if not request.form.get('name') or not request.form.get('steam_key'):
flash(translate('Name and Steam Key are required'), 'error')
return redirect(url_for('edit_game', game_id=game_id))
# Duplicate check
existing = Game.query.filter(
Game.steam_key == request.form['steam_key'],
Game.id != game.id,
Game.user_id == current_user.id
).first()
if existing:
flash(translate('Steam Key already exists'), 'error')
return redirect(url_for('edit_game', game_id=game_id))
# Update fields
2025-04-21 10:45:06 +00:00
game.name = request.form['name']
game.steam_key = request.form['steam_key']
game.status = request.form['status']
game.platform = request.form.get('platform', 'pc')
2025-04-21 10:45:06 +00:00
game.recipient = request.form.get('recipient', '')
game.notes = request.form.get('notes', '')
game.url = request.form.get('url', '')
game.steam_appid = request.form.get('steam_appid', '')
game.redeem_date = safe_parse_date(request.form.get('redeem_date', ''))
2025-04-26 12:32:07 +00:00
# Token-Logic
if game.status == 'geschenkt':
# Vorhandene Tokens löschen
RedeemToken.query.filter_by(game_id=game.id).delete()
# Generate new Token
token = secrets.token_urlsafe(12)[:17]
expires = datetime.now(local_tz) + timedelta(hours=24)
new_token = RedeemToken(
token=token,
game_id=game.id,
expires=expires,
total_hours=24
)
db.session.add(new_token)
2025-04-21 10:45:06 +00:00
db.session.commit()
flash(translate('Changes saved successfully'), 'success')
2025-04-21 10:45:06 +00:00
return redirect(url_for('index'))
except IntegrityError as e:
db.session.rollback()
app.logger.error(f"IntegrityError: {traceback.format_exc()}")
flash(translate('Database error: {error}', error=str(e.orig)), 'error')
2025-04-21 10:45:06 +00:00
except Exception as e:
db.session.rollback()
app.logger.error(f"Unexpected error: {traceback.format_exc()}")
flash(translate('Unexpected error: {error}', error=str(e)), 'error')
return render_template(
'edit_game.html',
game=game,
platforms=PLATFORM_CHOICES,
statuses=STATUS_CHOICES,
redeem_date=game.redeem_date.strftime('%Y-%m-%d') if game.redeem_date else ''
)
2025-04-26 12:32:07 +00:00
2025-04-21 10:45:06 +00:00
@app.route('/delete/<int:game_id>', methods=['POST'])
@login_required
def delete_game(game_id):
game = Game.query.get_or_404(game_id)
db.session.delete(game)
db.session.commit()
flash(translate('Game deleted successfully'), 'success')
2025-04-21 10:45:06 +00:00
return redirect(url_for('index'))
2025-04-22 11:20:15 +00:00
@app.route('/export', methods=['GET'])
@login_required
def export_games():
games = Game.query.filter_by(user_id=current_user.id).all()
output = io.StringIO()
writer = csv.writer(output)
2025-04-26 12:32:07 +00:00
2025-04-22 11:20:15 +00:00
writer.writerow(['Name', 'Steam Key', 'Status', 'Recipient', 'Notes', 'URL', 'Created', 'Redeem by', 'Steam AppID'])
2025-04-26 12:32:07 +00:00
2025-04-22 11:20:15 +00:00
for game in games:
writer.writerow([
2025-04-26 12:32:07 +00:00
game.name,
game.steam_key,
game.status,
game.recipient,
game.notes,
game.url,
game.created_at.strftime('%Y-%m-%d %H:%M:%S') if game.created_at else '',
2025-04-22 11:20:15 +00:00
game.redeem_date.strftime('%Y-%m-%d') if game.redeem_date else '',
game.steam_appid
])
2025-04-26 12:32:07 +00:00
2025-04-22 11:20:15 +00:00
output.seek(0)
return send_file(
io.BytesIO(output.getvalue().encode('utf-8')),
mimetype='text/csv',
as_attachment=True,
download_name='games_export.csv'
)
2025-04-26 12:32:07 +00:00
@app.route('/export_pdf')
@login_required
def export_pdf():
excluded_statuses = ['eingelöst', 'verschenkt']
games = Game.query.filter(
Game.user_id == current_user.id,
Game.status.notin_(excluded_statuses)
).order_by(Game.created_at.desc()).all()
buffer = io.BytesIO()
doc = SimpleDocTemplate(buffer,
pagesize=landscape(A4),
leftMargin=40,
rightMargin=40,
topMargin=40,
bottomMargin=40
)
styles = getSampleStyleSheet()
elements = []
img_height = 2*cm
# Title
elements.append(Paragraph(
translate("Game List (without Keys)", lang=session.get('lang', 'en')),
styles['Title']
))
2025-04-26 12:32:07 +00:00
elements.append(Spacer(1, 12))
# Table header
2025-04-26 12:32:07 +00:00
col_widths = [
5*cm, 10*cm, 6*cm, 3*cm
]
data = [[
Paragraph('<b>Cover</b>', styles['Normal']),
Paragraph('<b>Name</b>', styles['Normal']),
Paragraph('<b>Shop-Link</b>', styles['Normal']),
Paragraph('<b>Einlösen bis</b>', styles['Normal'])
]]
for game in games:
img = None
if game.steam_appid:
try:
img_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{game.steam_appid}/header.jpg"
img_data = io.BytesIO(requests.get(img_url, timeout=5).content)
img = Image(img_data, width=3*cm, height=img_height)
2025-05-02 09:11:09 +00:00
except Exception:
img = Paragraph('', styles['Normal'])
2025-05-01 11:23:46 +00:00
elif game.url and 'gog.com' in game.url:
try:
2025-05-02 12:07:56 +00:00
img_path = os.path.join(app.root_path, 'static', 'gog_logo.webp')
2025-05-01 11:23:46 +00:00
img = Image(img_path, width=3*cm, height=img_height)
2025-04-26 12:32:07 +00:00
except Exception:
img = Paragraph('', styles['Normal'])
data.append([
img or '',
Paragraph(game.name, styles['Normal']),
Paragraph(game.url or '', styles['Normal']),
game.redeem_date.strftime('%d.%m.%y') if game.redeem_date else ''
])
# Table format
2025-04-26 12:32:07 +00:00
table = Table(data, colWidths=col_widths, repeatRows=1)
table.setStyle(TableStyle([
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
('FONTSIZE', (0,0), (-1,0), 8),
('FONTSIZE', (0,1), (-1,-1), 8),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('ALIGN', (0,0), (-1,-1), 'LEFT'),
('GRID', (0,0), (-1,-1), 0.5, colors.lightgrey),
('WORDWRAP', (1,1), (1,-1), 'CJK'),
]))
elements.append(table)
doc.build(elements)
buffer.seek(0)
2025-05-02 09:11:09 +00:00
return send_file(
2025-04-26 12:32:07 +00:00
buffer,
mimetype='application/pdf',
as_attachment=True,
download_name=f'game_export_{datetime.now().strftime("%Y%m%d")}.pdf'
)
2025-05-02 09:11:09 +00:00
2025-04-22 11:20:15 +00:00
@app.route('/import', methods=['GET', 'POST'])
@login_required
def import_games():
if request.method == 'POST':
file = request.files.get('file')
2025-04-26 12:32:07 +00:00
2025-04-22 11:20:15 +00:00
if file and file.filename.endswith('.csv'):
2025-04-26 12:32:07 +00:00
stream = io.StringIO(file.stream.read().decode("UTF8"))
2025-04-22 11:20:15 +00:00
reader = csv.DictReader(stream)
2025-04-26 12:32:07 +00:00
new_games = 0
duplicates = 0
try:
with db.session.begin_nested():
for row in reader:
steam_key = row['Steam Key'].strip()
if Game.query.filter_by(steam_key=steam_key).first():
duplicates += 1
continue
game = Game(
name=row['Name'],
steam_key=steam_key,
status=row['Status'],
recipient=row.get('Recipient', ''),
notes=row.get('Notes', ''),
url=row.get('URL', ''),
created_at=datetime.strptime(row['Created'], '%Y-%m-%d %H:%M:%S') if row.get('Created') else datetime.utcnow(),
redeem_date=datetime.strptime(row['Redeem by'], '%Y-%m-%d') if row.get('Redeem by') else None,
steam_appid=row.get('Steam AppID', ''),
user_id=current_user.id
)
db.session.add(game)
new_games += 1
db.session.commit()
flash(translate("new_games_imported", new=new_games, dup=duplicates), 'success')
2025-04-26 12:32:07 +00:00
except Exception as e:
db.session.rollback()
flash(translate('Import error: {error}', error=str(e)), 'danger')
2025-04-26 12:32:07 +00:00
2025-04-22 11:20:15 +00:00
return redirect(url_for('index'))
2025-04-26 12:32:07 +00:00
flash(translate('Please upload a valid CSV file.'), 'danger')
2025-04-26 12:32:07 +00:00
2025-04-22 11:20:15 +00:00
return render_template('import.html')
2025-04-21 10:45:06 +00:00
2025-04-26 12:32:07 +00:00
@app.route('/generate_redeem/<int:game_id>', methods=['POST'])
@login_required
def generate_redeem(game_id):
game = Game.query.get_or_404(game_id)
if game.user_id != current_user.id or game.status != 'geschenkt':
return jsonify({'error': translate('Forbidden')}), 403
2025-04-26 12:32:07 +00:00
try:
RedeemToken.query.filter_by(game_id=game_id).delete()
token = secrets.token_urlsafe(12)[:17]
expires = datetime.now(local_tz) + timedelta(hours=24)
2025-04-26 12:32:07 +00:00
new_token = RedeemToken(
token=token,
game_id=game_id,
expires=expires,
total_hours=24
)
db.session.add(new_token)
db.session.commit()
redeem_url = url_for('redeem', token=token, _external=True)
message = translate(
'Redeem link generated: <a href="{url}" target="_blank">{url}</a>',
url=redeem_url
)
return jsonify({'url': redeem_url, 'message': message})
2025-04-26 12:32:07 +00:00
except Exception as e:
db.session.rollback()
2025-04-26 12:32:07 +00:00
return jsonify({'error': str(e)}), 500
@app.route('/redeem/<token>', endpoint='redeem')
2025-04-26 12:32:07 +00:00
def redeem_page(token):
redeem_token = RedeemToken.query.filter_by(token=token).first()
if not redeem_token:
abort(404)
expires_utc = redeem_token.expires.astimezone(pytz.UTC)
if datetime.now(pytz.UTC) > expires_utc:
2025-04-26 12:32:07 +00:00
db.session.delete(redeem_token)
db.session.commit()
abort(404)
game = Game.query.get(redeem_token.game_id)
redeem_token.used = True
db.session.commit()
2025-05-09 09:31:50 +00:00
# which Plattform
if game.platform == "steam" or game.steam_appid:
platform_link = 'https://store.steampowered.com/account/registerkey?key='
platform_label = "Steam"
elif game.platform == "gog":
platform_link = 'https://www.gog.com/redeem/'
platform_label = "GOG"
elif game.platform == "xbox":
platform_link = 'https://redeem.microsoft.com/'
platform_label = "XBOX"
elif game.platform == "playstation":
platform_link = 'https://store.playstation.com/redeem'
platform_label = "PlayStation"
else:
platform_link = '#'
platform_label = game.platform.capitalize() if game.platform else "Unknown"
return render_template(
'redeem.html',
game=game,
redeem_token=redeem_token,
expires_timestamp=int(expires_utc.timestamp() * 1000),
platform_link=platform_link,
platform_label=platform_label
)
2025-04-26 12:32:07 +00:00
@app.route('/admin/users')
@login_required
@admin_required
def admin_users():
users = User.query.all()
return render_template('admin_users.html', users=users)
@app.route('/admin/users/delete/<int:user_id>', methods=['POST'])
@login_required
@admin_required
def admin_delete_user(user_id):
if current_user.id == user_id:
flash(translate('You cannot delete yourself'), 'error')
return redirect(url_for('admin_users'))
user = User.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
log_activity(
current_user.id,
'user_deleted',
f"Deleted user: {user.username} (ID: {user.id})"
)
flash(translate('User deleted successfully'), 'success')
return redirect(url_for('admin_users'))
@app.route('/admin/users/reset_password/<int:user_id>', methods=['POST'])
@login_required
@admin_required
def admin_reset_password(user_id):
user = User.query.get_or_404(user_id)
new_password = secrets.token_urlsafe(8)
user.password = generate_password_hash(new_password)
db.session.commit()
log_activity(
current_user.id,
'user_newpassword',
f"New password for user: {user.username} (ID: {user.id})"
)
flash(
translate('New password for {username}: {password}',
username=user.username,
password=new_password),
'info'
)
return redirect(url_for('admin_users'))
@app.route('/admin/audit-logs')
@login_required
@admin_required
def admin_audit_logs():
page = request.args.get('page', 1, type=int)
logs = ActivityLog.query.order_by(ActivityLog.timestamp.desc()).paginate(page=page, per_page=20)
return render_template('admin_audit_logs.html', logs=logs)
@app.route('/game/<int:game_id>/update', methods=['POST'])
@login_required
def update_game_data(game_id):
game = Game.query.get_or_404(game_id)
# 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-Data (Multilingual)
if steam_appid:
try:
app.logger.debug(f"🔍 Fetching Steam data for AppID: {steam_appid}")
for lang in ['en', 'de']:
steam_data = fetch_steam_data(steam_appid, lang=lang)
if steam_data:
if lang == 'en' and steam_data.get("name"):
game.name = steam_data.get("name", game.name)
setattr(game, f'steam_description_{lang}', steam_data.get("detailed_description") or "No Infos available")
if lang == 'en':
date_str = steam_data.get("release_date", {})
if date_str:
parsed_date = parse_steam_release_date(date_str)
if parsed_date:
game.release_date = local_tz.localize(parsed_date)
else:
app.logger.warning(f"Could not parse Steam release date: {date_str}")
app.logger.info("✅ Steam data successfully updated")
except Exception as e:
app.logger.error(f"💥 Kritischer Steam-Fehler: {str(e)}", exc_info=True)
flash(translate('Error during Steam query'), 'danger')
else:
app.logger.warning("⚠️ Keine Steam-AppID vorhanden, Steam-Daten werden nicht aktualisiert")
flash(translate('Steam-AppID missing, no Steam Data transferred'), 'warning')
# ITAD-Slug doings and such
itad_slug = fetch_itad_slug(steam_appid)
if itad_slug:
game.itad_slug = itad_slug
# 4. ITAD-Prices
price_data = None
if steam_appid:
try:
app.logger.debug("🔄 Starte ITAD-Abfrage...")
game.itad_game_id = fetch_itad_game_id(steam_appid)
if game.itad_game_id:
app.logger.info(f"🔑 ITAD Game ID: {game.itad_game_id}")
price_data = fetch_itad_prices(game.itad_game_id)
if price_data:
# 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}€")
game.historical_low = price_data.get("historyLow", {}).get("all", {}).get("amount")
app.logger.info(f"📉 Historical Low: {game.historical_low}€")
else:
app.logger.warning("⚠️ Keine ITAD-Preisdaten erhalten")
else:
app.logger.warning("⚠️ Keine ITAD Game ID erhalten")
except Exception as e:
app.logger.error(f"💥 ITAD-API-Fehler: {str(e)}", exc_info=True)
flash(translate('Fehler bei Preisabfrage'), 'danger')
try:
db.session.commit()
flash(translate('Externe Daten erfolgreich aktualisiert!'), 'success')
app.logger.info("💾 Datenbank-Update erfolgreich")
except Exception as e:
db.session.rollback()
app.logger.error(f"💥 Datenbank-Fehler: {str(e)}", exc_info=True)
flash(translate('Fehler beim Speichern der Daten'), 'danger')
return redirect(url_for('edit_game', game_id=game_id))
@app.route('/game/<int:game_id>')
@login_required
def game_details(game_id):
game = Game.query.get_or_404(game_id)
return render_template('game_details.html', game=game)
@app.route('/debug-session')
def debug_session():
return jsonify(dict(session))
# Apprise Notifications
import apprise
2025-04-26 12:32:07 +00:00
def send_apprise_notification(user, game):
apprise_urls = os.getenv('APPRISE_URLS', '').strip()
if not apprise_urls:
app.logger.error("No APPRISE_URLS configured")
2025-04-26 12:32:07 +00:00
return False
apobj = apprise.Apprise()
for url in apprise_urls.replace(',', '\n').splitlines():
if url.strip():
apobj.add(url.strip())
edit_url = url_for('edit_game', game_id=game.id, _external=True)
result = apobj.notify(
title="Steam-Key läuft ab!",
body=f"Dein Key für '{game.name}' läuft in weniger als 48 Stunden ab!\n\nLink: {edit_url}",
)
return result
2025-04-26 12:32:07 +00:00
def send_notification(user, game):
return send_apprise_notification(user, game)
2025-04-26 12:32:07 +00:00
def check_expiring_keys():
now = datetime.now(local_tz)
expiry_threshold = now + timedelta(hours=48)
stmt = select(Game).where(
Game.status != 'eingelöst',
Game.redeem_date <= expiry_threshold,
Game.redeem_date > now
)
expiring_games = db.session.execute(stmt).scalars().all()
for game in expiring_games:
user = User.query.get(game.user_id)
if user.notification_service and user.notification_service != 'none':
send_notification(user, game)
2025-04-26 12:32:07 +00:00
2025-04-29 12:46:55 +00:00
# Optional: cleaning up old tokens
2025-04-26 12:32:07 +00:00
def cleanup_expired_tokens():
with app.app_context():
try:
now = datetime.now(local_tz)
expired = RedeemToken.query.filter(RedeemToken.expires < now).all()
for token in expired:
db.session.delete(token)
db.session.commit()
app.logger.info(f"Cleaned up {len(expired)} expired tokens.")
except Exception as e:
app.logger.error(f"Error during cleanup_expired_tokens: {e}")
db.session.rollback()
2025-04-26 12:32:07 +00:00
2025-04-29 12:46:55 +00:00
# Scheduler start
scheduler = BackgroundScheduler(timezone=str(local_tz))
2025-04-26 12:32:07 +00:00
def check_expiring_keys_job():
with app.app_context():
check_expiring_keys()
def cleanup_expired_tokens_job():
with app.app_context():
cleanup_expired_tokens()
# Add Jobs
scheduler.add_job(
check_expiring_keys_job,
'interval',
hours=int(os.getenv('CHECK_EXPIRING_KEYS_INTERVAL_HOURS', 12)),
id='check_expiring_keys'
)
scheduler.add_job(
cleanup_expired_tokens_job,
'interval',
hours=1,
id='cleanup_expired_tokens'
)
# price updates
def update_prices_job():
with app.app_context():
games = Game.query.filter(Game.steam_appid.isnot(None)).all()
for game in games:
# just update prices
itad_data = fetch_itad_data(f"app/{game.steam_appid}")
if itad_data:
game.current_price = itad_data.get('price_new')
game.historical_low = itad_data.get('price_low', {}).get('amount')
db.session.commit()
scheduler.add_job(
update_prices_job,
'interval',
hours=12,
id='update_prices'
)
def update_missing_steam_descriptions_job():
with app.app_context():
games = Game.query.filter(
(Game.steam_description_en == None) | (Game.steam_description_en == '') |
(Game.steam_description_de == None) | (Game.steam_description_de == '')
).all()
for game in games:
for lang in ['en', 'de']:
if not getattr(game, f'steam_description_{lang}', None):
steam_data = fetch_steam_data(game.steam_appid, lang=lang)
if steam_data:
setattr(game, f'steam_description_{lang}', steam_data.get('detailed_description'))
db.session.commit()
scheduler.add_job(
update_missing_steam_descriptions_job,
'interval',
hours=24,
id='update_missing_steam_descriptions'
)
# start Scheduler
scheduler.start()
atexit.register(lambda: scheduler.shutdown(wait=False))
2025-04-26 12:32:07 +00:00
2025-04-21 10:45:06 +00:00
if __name__ == '__main__':
with app.app_context():
db.create_all()
app.run(debug=True, host='0.0.0.0', port=5000)
2025-04-26 12:32:07 +00:00
2025-04-21 10:45:06 +00:00
PYTHON_END
# Templates - this was the "fun" part
mkdir -p templates static
2025-04-21 10:45:06 +00:00
# Base Template
cat <<HTML_END > templates/base.html
<!DOCTYPE html>
<html lang="{{ session.get('lang', 'en') }}" data-bs-theme="{{ theme }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="description" content="Manage your Steam and GOG keys efficiently. Track redemption dates, share games, and export lists.">
<meta name="theme-color" content="#212529">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<title>{{ _('Game Key Manager') }}</title>
<!-- Preload Bootstrap CSS for better LCP -->
<link rel="preload" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"></noscript>
<!-- My Styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% if games and games[0].steam_appid %}
<link rel="preload"
as="image"
href="https://cdn.cloudflare.steamstatic.com/steam/apps/{{ games[0].steam_appid }}/header.jpg"
imagesrcset="https://cdn.cloudflare.steamstatic.com/steam/apps/{{ games[0].steam_appid }}/header.jpg 368w"
fetchpriority="high"
type="image/jpeg">
{% endif %}
</head>
<script>
(function() {
try {
var theme = localStorage.getItem('theme');
if (!theme) {
// Systempräferenz als Fallback
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-bs-theme', theme);
} catch(e) {}
})();
</script>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container">
<a class="navbar-brand d-flex align-items-center gap-2" href="/">
<img src="{{ url_for('static', filename='logo_small.webp') }}" alt="Logo" width="36" height="28" style="object-fit:contain; border-radius:8px;">
<span>Game Key Manager</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavbar" aria-controls="mainNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse flex-grow-1" id="mainNavbar">
<form class="d-flex ms-auto my-2 my-lg-0" action="{{ url_for('index') }}" method="GET" role="search" aria-label="{{ _('Search games') }}">
<input class="form-control me-2" type="search" name="q" id="searchInput" placeholder="{{ _('Search') }}" value="{{ search_query }}">
<button class="btn btn-outline-success" type="submit" aria-label="{{ _('Search') }}">🔍</button>
</form>
<ul class="navbar-nav ms-lg-3 mb-2 mb-lg-0">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="langDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{% if session.get('lang', 'en') == 'de' %} Deutsch {% elif session.get('lang', 'en') == 'en' %} English {% else %} Sprache {% endif %}
</a>
<ul class="dropdown-menu" aria-labelledby="langDropdown">
<li><a class="dropdown-item {% if session.get('lang', 'en') == 'de' %}active{% endif %}" href="{{ url_for('set_lang', lang='de') }}">Deutsch</a></li>
<li><a class="dropdown-item {% if session.get('lang', 'en') == 'en' %}active{% endif %}" href="{{ url_for('set_lang', lang='en') }}">English</a></li>
</ul>
</li>
{% if current_user.is_authenticated %}
{% if current_user.is_admin %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin_users') }}">⚙️ {{ _('Admin') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin_audit_logs') }}">📜 {{ _('Audit Logs') }}</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('change_password') }}">🔒 {{ _('Password') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('logout') }}">🚪 {{ _('Logout') }}</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
<div class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-container">
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Service Worker Registration for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('{{ url_for("static", filename="serviceworker.js") }}', {scope: '/'})
.then(registration => {
console.log('ServiceWorker registered:', registration.scope);
})
.catch(error => {
console.log('ServiceWorker registration failed:', error);
});
});
}
// Dark Mode Switch
document.addEventListener('DOMContentLoaded', function() {
const toggle = document.getElementById('darkModeSwitch');
const html = document.documentElement;
if (toggle) {
toggle.checked = (html.getAttribute('data-bs-theme') === 'dark')
toggle.addEventListener('change', function() {
const theme = this.checked ? 'dark' : 'light';
document.cookie = "theme=" + theme + ";path=/;max-age=31536000";
html.setAttribute('data-bs-theme', theme);
fetch('/set-theme/' + theme);
});
}
// Set theme on page load
function getThemeCookie() {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'theme') return value;
}
return null;
}
const savedTheme = getThemeCookie() || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-bs-theme', savedTheme);
});
2025-05-02 12:49:43 +00:00
2025-04-21 10:45:06 +00:00
</script>
2025-04-26 12:32:07 +00:00
{% include "footer.html" %}
2025-04-21 10:45:06 +00:00
</body>
</html>
HTML_END
2025-04-26 12:32:07 +00:00
# Index Template
cat <<'HTML_END' > templates/index.html
2025-04-21 10:45:06 +00:00
{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>{{ _('My Games') }}</h1>
2025-04-26 12:32:07 +00:00
<div>
<a href="{{ url_for('export_games') }}" class="btn btn-outline-secondary">⬇️ {{ _('Export CSV') }}</a>
<a href="{{ url_for('export_pdf') }}" class="btn btn-outline-secondary">⬇️ Export PDF (for sharing)</a>
<a href="{{ url_for('import_games') }}" class="btn btn-outline-secondary">⬆️ {{ _('Import CSV') }}</a>
<a href="{{ url_for('add_game') }}" class="btn btn-primary">+ {{ _('Add New Game') }}</a>
</div>
2025-04-21 10:45:06 +00:00
</div>
{% if games %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-dark">
<tr>
2025-04-21 11:06:38 +00:00
<th>{{ _('Cover') }}</th>
2025-04-21 10:45:06 +00:00
<th>{{ _('Name') }}</th>
<th>{{ _('Key') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Created') }}</th>
<th>{{ _('Redeem by') }}</th>
<th>{{ _('Shop') }}</th>
<th>{{ _('Price') }}</th>
2025-04-21 10:45:06 +00:00
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for game in games %}
<tr>
2025-04-21 11:06:38 +00:00
<td>
<a href="{{ url_for('game_details', game_id=game.id) }}" title="{{ _('Details') }}">
{% if game.steam_appid %}
<img src="https://cdn.cloudflare.steamstatic.com/steam/apps/{{ game.steam_appid }}/header.jpg"
alt="Steam Header"
class="game-cover"
{% if loop.first %}fetchpriority="high"{% endif %}
width="368"
height="172"
loading="lazy">
{% elif game.url and 'gog.com' in game.url %}
<img src="{{ url_for('static', filename='gog_logo.webp') }}"
alt="GOG Logo"
class="game-cover"
width="368"
height="172"
loading="lazy">
{% endif %}
</a>
2025-04-21 11:06:38 +00:00
</td>
2025-04-21 10:45:06 +00:00
<td>{{ game.name }}</td>
<td class="font-monospace">{{ game.steam_key }}</td>
<td>
{% if game.status == 'nicht eingelöst' %}
<span class="badge bg-warning text-dark">{{ _('Not redeemed') }}</span>
{% elif game.status == 'geschenkt' %}
2025-04-21 10:45:06 +00:00
<span class="badge bg-success">{{ _('Gifted') }}</span>
{% elif game.status == 'eingelöst' %}
<span class="badge bg-secondary">{{ _('Redeemed') }}</span>
{% endif %}
</td>
<td>{{ game.created_at|strftime('%d.%m.%Y') }}</td>
2025-04-21 10:45:06 +00:00
<td>
{% if game.redeem_date %}
<span class="badge bg-danger">{{ game.redeem_date|strftime('%d.%m.%Y') }}</span>
2025-04-21 10:45:06 +00:00
{% endif %}
</td>
<td>
{% if game.url %}
<a href="{{ game.url }}" target="_blank" class="btn btn-sm btn-outline-info">🔗 {{ _('Shop') }}</a>
{% endif %}
</td>
<td>
{% if game.current_price is not none %}
<div {% if game.historical_low is not none %}class="mb-2"{% endif %}>
<div class="text-body-secondary" style="font-size: 0.85em; line-height: 1.2;">
{{ _('Current Deal') }}
</div>
<div style="font-size: 1.05em; line-height: 1.2;">
{{ "%.2f"|format(game.current_price) }}
{% if game.current_price_shop %}
<span class="d-block text-body-secondary" style="font-size: 0.75em; line-height: 1.1;">({{ game.current_price_shop }})</span>
{% endif %}
</div>
</div>
{% endif %}
{# Historical Low #}
{% if game.historical_low is not none %}
<div>
<div class="text-body-secondary" style="font-size: 0.85em; line-height: 1.2;">
{{ _('Hist. Low') }}
</div>
<div style="font-size: 1.05em; line-height: 1.2;">
{{ "%.2f"|format(game.historical_low) }}
</div>
</div>
{% endif %}
</td>
2025-04-21 10:45:06 +00:00
<td class="text-nowrap">
{% if game.status == 'geschenkt' %}
<button type="button"
class="btn btn-sm btn-success generate-redeem"
2025-04-26 12:32:07 +00:00
data-game-id="{{ game.id }}"
title="{{ _('Generate redeem link') }}">
🔗
</button>
{% endif %}
2025-04-21 11:06:38 +00:00
<a href="{{ url_for('edit_game', game_id=game.id) }}" class="btn btn-sm btn-warning">✏️</a>
<form method="POST" action="{{ url_for('delete_game', game_id=game.id) }}" class="d-inline">
2025-04-26 12:32:07 +00:00
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
2025-04-21 11:06:38 +00:00
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('{{ _('Really delete?') }}')">🗑️</button>
2025-04-21 10:45:06 +00:00
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
2025-04-26 12:32:07 +00:00
<script>
document.querySelectorAll('.generate-redeem').forEach(btn => {
btn.addEventListener('click', async function() {
const gameId = this.dataset.gameId;
const flashContainer = document.querySelector('.flash-container');
2025-04-26 12:32:07 +00:00
try {
const response = await fetch(`/generate_redeem/${gameId}`, {
2025-04-26 12:32:07 +00:00
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
2025-04-26 12:32:07 +00:00
}
});
2025-04-26 12:32:07 +00:00
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '{{ _("Unknown error") }}');
}
if (data.url) {
2025-04-26 12:32:07 +00:00
await navigator.clipboard.writeText(data.url);
// Erfolgsmeldung mit übersetztem Text
flashContainer.innerHTML = `
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ _("Link copied") }}: <a href="${data.url}" target="_blank">${data.url}</a>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
2025-04-26 12:32:07 +00:00
}
} catch (error) {
// Fehlermeldung mit übersetztem Text
flashContainer.innerHTML = `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ _("Error") }}: ${error.message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
2025-04-26 12:32:07 +00:00
}
});
});
</script>
2025-04-21 10:45:06 +00:00
{% else %}
<div class="alert alert-info">{{ _('No games yet') }}</div>
{% endif %}
{% endblock %}
HTML_END
2025-04-26 12:32:07 +00:00
# Login Template
cat <<HTML_END > templates/login.html
{% extends "base.html" %}
{% block content %}
2025-05-02 12:49:43 +00:00
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
2025-05-08 13:31:53 +00:00
<h1 class="mb-4 text-center">{{ _('Login') }}</h1>
2025-05-02 12:49:43 +00:00
<form method="POST" aria-label="{{ _('Login form') }}" autocomplete="on">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="username" class="form-label">{{ _('Username') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="text"
id="username"
name="username"
class="form-control"
required
autocomplete="username"
aria-required="true"
autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">{{ _('Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="password"
id="password"
name="password"
class="form-control"
required
autocomplete="current-password"
aria-required="true">
</div>
2025-05-08 13:31:53 +00:00
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember_me" name="remember_me" value="true">
<label class="form-check-label" for="remember_me">{{ _('Remember me') }}</label>
</div>
{# Flash messages are handled in base.html, so the specific error block here can be removed #}
{# {% if error %}
2025-05-02 12:49:43 +00:00
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
2025-05-08 13:31:53 +00:00
{% endif %} #}
<button type="submit" class="btn btn-primary w-100 mb-3">{{ _('Login') }}</button>
2025-05-02 12:49:43 +00:00
</form>
2025-05-08 13:31:53 +00:00
{% if config.REGISTRATION_ENABLED %}
2025-05-02 12:49:43 +00:00
<div class="mt-3 text-center">
2025-05-08 13:31:53 +00:00
<a href="{{ url_for('register') }}">{{ _('No account? Register here!') }}</a>
2025-04-26 12:32:07 +00:00
</div>
2025-05-08 13:31:53 +00:00
{% endif %}
2025-05-02 12:49:43 +00:00
</div>
2025-04-26 12:32:07 +00:00
</div>
{% endblock %}
2025-04-21 10:45:06 +00:00
2025-04-26 12:32:07 +00:00
HTML_END
2025-04-21 11:06:38 +00:00
2025-04-26 12:32:07 +00:00
# Register Template
cat <<HTML_END > templates/register.html
{% extends "base.html" %}
{% block content %}
2025-05-02 12:49:43 +00:00
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<h1 class="mb-4">{{ _('Register') }}</h1>
<form method="POST" aria-label="{{ _('Registration form') }}" autocomplete="on">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="reg-username" class="form-label">{{ _('Username') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="text"
id="reg-username"
name="username"
class="form-control"
required
autocomplete="username"
aria-required="true">
</div>
<div class="mb-3">
<label for="reg-password" class="form-label">{{ _('Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="password"
id="reg-password"
name="password"
class="form-control"
required
autocomplete="new-password"
aria-required="true">
</div>
<div class="mb-3">
<label for="reg-password2" class="form-label">{{ _('Confirm Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="password"
id="reg-password2"
name="password2"
class="form-control"
required
autocomplete="new-password"
aria-required="true">
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% endif %}
<button type="submit" class="btn btn-primary w-100">{{ _('Register') }}</button>
</form>
<div class="mt-3 text-center">
<a href="{{ url_for('login') }}">{{ _('Already have an account? Login!') }}</a>
2025-04-26 12:32:07 +00:00
</div>
2025-05-02 12:49:43 +00:00
</div>
2025-04-26 12:32:07 +00:00
</div>
{% endblock %}
2025-05-02 12:49:43 +00:00
2025-04-26 12:32:07 +00:00
HTML_END
# Change Password Template
cat <<HTML_END > templates/change_password.html
2025-04-21 10:45:06 +00:00
{% extends "base.html" %}
{% block content %}
2025-05-02 12:49:43 +00:00
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card p-4 shadow-sm">
<h2 class="mb-4">{{ _('Change Password') }}</h2>
<form method="POST" aria-label="{{ _('Change password form') }}">
2025-04-26 12:32:07 +00:00
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
2025-05-02 12:49:43 +00:00
<label for="current_password" class="form-label">{{ _('Current Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="password" id="current_password" name="current_password" class="form-control" required autocomplete="current-password" aria-required="true">
2025-04-26 12:32:07 +00:00
</div>
<div class="mb-3">
2025-05-02 12:49:43 +00:00
<label for="new_password" class="form-label">{{ _('New Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="password" id="new_password" name="new_password" class="form-control" required autocomplete="new-password" aria-required="true">
2025-04-26 12:32:07 +00:00
</div>
<div class="mb-3">
2025-05-02 12:49:43 +00:00
<label for="confirm_password" class="form-label">{{ _('Confirm New Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="password" id="confirm_password" name="confirm_password" class="form-control" required autocomplete="new-password" aria-required="true">
2025-04-26 12:32:07 +00:00
</div>
<button type="submit" class="btn btn-primary">{{ _('Change Password') }}</button>
2025-05-02 12:49:43 +00:00
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary ms-2">{{ _('Cancel') }}</a>
</form>
</div>
</div>
2025-04-26 12:32:07 +00:00
</div>
{% endblock %}
2025-05-02 12:49:43 +00:00
2025-04-26 12:32:07 +00:00
HTML_END
# Edit Game Template
cat <<HTML_END > templates/edit_game.html
{% extends "base.html" %}
{% block content %}
<div class="card p-4 shadow-sm">
<h2 class="mb-4">{{ _('Spiel bearbeiten') }}</h2>
2025-05-08 14:05:13 +00:00
<!-- Flash-Nachrichten -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages mb-4">
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
2025-05-08 14:05:13 +00:00
2025-05-08 14:20:49 +00:00
<!-- Update Data Form (separate, outside main form, uses POST) -->
<div class="mb-3 text-end">
<form method="POST" action="{{ url_for('update_game_data', game_id=game.id) }}" id="updateDataForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Ändere die ID für Eindeutigkeit -->
<input type="hidden" name="steam_appid" id="itad_steam_appid" value="{{ game.steam_appid }}">
<button type="submit" class="btn btn-secondary">
🔄 {{ _('Update Data') }}
</button>
</form>
<script>
document.getElementById('updateDataForm').addEventListener('submit', function(e) {
e.preventDefault();
const currentAppId = document.getElementById('game_appid').value;
document.getElementById('itad_steam_appid').value = currentAppId;
this.submit();
});
</script>
2025-05-08 14:20:49 +00:00
</div>
<form method="POST" aria-label="{{ _('Spiel bearbeiten') }}">
2025-05-02 12:49:43 +00:00
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row g-3">
2025-05-08 14:05:13 +00:00
<!-- Formularfelder -->
<div class="col-md-6">
<label class="form-label">{{ _('Name') }} <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control" value="{{ game.name }}" required>
</div>
<div class="col-md-6">
<label for="game_platform" class="form-label">{{ _('Platform') }} <span class="text-danger">*</span></label>
<select id="game_platform" name="platform" class="form-select" required>
{% for value, label in platforms %}
2025-05-08 14:05:13 +00:00
<option value="{{ value }}" {% if game.platform == value %}selected{% endif %}>{{ _(label) }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label for="game_status" class="form-label">{{ _('Status') }} <span class="text-danger">*</span></label>
<select id="game_status" name="status" class="form-select" required>
{% for value, label in statuses %}
2025-05-08 14:05:13 +00:00
<option value="{{ value }}" {% if game.status == value %}selected{% endif %}>{{ _(label) }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">{{ _('Steam Key') }} <span class="text-danger">*</span></label>
<input type="text" name="steam_key" class="form-control" value="{{ game.steam_key }}" required>
</div>
<div class="col-md-6">
2025-05-08 14:05:13 +00:00
<label for="game_appid" class="form-label">{{ _('Steam AppID') }}</label>
<input type="text" id="game_appid" name="steam_appid" class="form-control" value="{{ game.steam_appid or '' }}">
<small class="text-muted">
{{ _('For GOG games: Enter the Steam AppID here to enable price tracking.') }}
</small>
</div>
<div class="col-md-6">
2025-05-08 14:05:13 +00:00
<label for="game_redeem_date" class="form-label">{{ _('Redeem by') }}</label>
<input type="date" id="game_redeem_date" name="redeem_date" class="form-control" value="{{ game.redeem_date.strftime('%Y-%m-%d') if game.redeem_date else '' }}">
</div>
<div class="col-12">
2025-05-08 14:05:13 +00:00
<label for="game_recipient" class="form-label">{{ _('Recipient') }}</label>
<input type="text" id="game_recipient" name="recipient" class="form-control" value="{{ game.recipient }}">
</div>
<div class="col-12">
2025-05-08 14:05:13 +00:00
<label for="game_url" class="form-label">{{ _('Shop URL') }}</label>
<input type="url" id="game_url" name="url" class="form-control" value="{{ game.url }}">
</div>
<div class="col-12">
2025-05-08 14:05:13 +00:00
<label for="game_notes" class="form-label">{{ _('Notes') }}</label>
<textarea id="game_notes" name="notes" class="form-control" rows="3">{{ game.notes }}</textarea>
</div>
2025-05-08 14:05:13 +00:00
<!-- Show External Data -->
<div class="col-12">
<div class="card mb-4">
2025-05-08 14:20:49 +00:00
<div class="card-header">
<span>🔄 {{ _('External Data') }}</span>
</div>
<div class="card-body">
{% if game.release_date %}
<div class="mb-2">
<strong>{{ _('Release Date:') }}</strong>
{{ game.release_date|strftime('%d.%m.%Y') }}
</div>
{% endif %}
2025-05-08 14:05:13 +00:00
{% if game.current_price %}
<div class="text-center mb-2">
<span class="badge bg-primary d-block">{{ _('Now') }}</span>
<div class="fw-bold" style="font-size:1.1em;">
{{ "%.2f"|format(game.current_price) }}
</div>
2025-05-08 14:05:13 +00:00
</div>
{% endif %}
{% if game.historical_low %}
<div class="text-center">
<span class="badge bg-secondary d-block">{{ _('Hist. Low') }}</span>
<div class="fw-bold" style="font-size:1.1em;">
{{ "%.2f"|format(game.historical_low) }}
</div>
2025-05-08 14:05:13 +00:00
</div>
{% endif %}
{% if game.itad_slug %}
2025-05-08 14:05:13 +00:00
<a href="https://isthereanydeal.com/game/{{ game.itad_slug }}/info/" target="_blank" rel="noopener" class="btn btn-outline-info mt-2">
🔗 {{ _('View on IsThereAnyDeal') }}
</a>
{% endif %}
</div>
</div>
</div>
<!-- Einlöse-Links -->
{% if game.status == 'geschenkt' %}
<div class="col-12">
<div class="card mb-3">
<div class="card-header">{{ _('Redeem-Link') }}</div>
<div class="card-body">
2025-05-08 14:05:13 +00:00
{% for token in game.redeem_tokens if not token.is_expired() %}
<div class="input-group mb-3">
<input type="text" class="form-control" value="{{ url_for('redeem', token=token.token, _external=True) }}" readonly id="redeem-link-{{ loop.index }}">
<button type="button" class="btn btn-outline-secondary copy-btn" data-clipboard-target="#redeem-link-{{ loop.index }}">
{{ _('Copy') }}
</button>
</div>
<small class="text-muted">
{{ _('Expires at') }}: {{ token.expires.astimezone(local_tz).strftime('%d.%m.%Y %H:%M') }}
</small>
{% else %}
<p class="text-muted mb-0">{{ _('No active redeem links') }}</p>
{% endfor %}
</div>
</div>
</div>
{% endif %}
2025-05-08 14:05:13 +00:00
<!-- Buttons -->
<div class="col-12">
<button type="submit" class="btn btn-primary">{{ _('Save') }}</button>
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary ms-2">{{ _('Cancel') }}</a>
2025-05-08 14:05:13 +00:00
<a href="{{ url_for('game_details', game_id=game.id) }}" class="btn btn-info ms-2">🔍 {{ _('View Details') }}</a>
</div>
</div>
</form>
</div>
<!-- Copy-JavaScript -->
<script>
2025-05-08 14:20:49 +00:00
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const input = document.querySelector(this.dataset.clipboardTarget);
try {
await navigator.clipboard.writeText(input.value);
this.innerHTML = '✅ {{ _("Copied!") }}';
setTimeout(() => this.innerHTML = '{{ _("Copy") }}', 2000);
} catch (err) {
this.innerHTML = '❌ {{ _("Error") }}';
setTimeout(() => this.innerHTML = '{{ _("Copy") }}', 2000);
}
});
});
</script>
{% endblock %}
2025-05-08 14:20:49 +00:00
HTML_END
cat <<HTML_END > templates/add_game.html
{% extends "base.html" %}
{% block content %}
<div class="card p-4 shadow-sm">
<h2 class="mb-4">{{ _('Add Game') }}</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-3">
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }}">
{{ message|safe }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST" aria-label="{{ _('Add Game') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row g-3">
<!-- Name -->
<div class="col-md-6">
<label for="game_name" class="form-label">{{ _('Name') }} <span class="text-danger">*</span></label>
<input type="text" id="game_name" name="name" class="form-control" value="{{ request.form.name or '' }}" required>
</div>
<!-- Steam Key -->
<div class="col-md-6">
<label for="game_key" class="form-label">{{ _('Game Key') }} <span class="text-danger">*</span></label>
<input type="text" id="game_key" name="steam_key" class="form-control" value="{{ request.form.steam_key or '' }}" required>
</div>
<!-- Platform Dropdown -->
2025-05-02 12:49:43 +00:00
<div class="col-md-6">
<label for="game_platform" class="form-label">{{ _('Platform') }} <span class="text-danger">*</span></label>
<select id="game_platform" name="platform" class="form-select" required>
{% for value, label in platforms %}
<option value="{{ value }}" {% if request.form.platform == value %}selected{% endif %}>
{{ _(label) }}
</option>
{% endfor %}
</select>
2025-05-02 12:49:43 +00:00
</div>
<!-- Status Dropdown -->
2025-05-02 12:49:43 +00:00
<div class="col-md-6">
<label for="game_status" class="form-label">{{ _('Status') }} <span class="text-danger">*</span></label>
<select id="game_status" name="status" class="form-select" required>
{% for value, label in statuses %}
<option value="{{ value }}" {% if request.form.status == value %}selected{% endif %}>
{{ _(label) }}
</option>
{% endfor %}
</select>
2025-05-02 12:49:43 +00:00
</div>
<!-- Steam AppID -->
2025-05-02 12:49:43 +00:00
<div class="col-md-6">
<label for="game_appid" class="form-label">{{ _('Steam AppID (optional)') }}</label>
<input type="text" id="game_appid" name="steam_appid" class="form-control" value="{{ request.form.steam_appid or '' }}">
2025-05-02 12:49:43 +00:00
</div>
<!-- Redeem Date -->
<div class="col-md-6">
2025-05-02 12:49:43 +00:00
<label for="game_redeem_date" class="form-label">{{ _('Redeem by') }}</label>
<input type="date" id="game_redeem_date" name="redeem_date" class="form-control" value="{{ request.form.redeem_date or '' }}">
2025-05-02 12:49:43 +00:00
</div>
<!-- Recipient -->
<div class="col-12">
2025-05-02 12:49:43 +00:00
<label for="game_recipient" class="form-label">{{ _('Recipient') }}</label>
<input type="text" id="game_recipient" name="recipient" class="form-control" value="{{ request.form.recipient or '' }}">
2025-05-02 12:49:43 +00:00
</div>
<!-- Shop URL -->
2025-05-02 12:49:43 +00:00
<div class="col-12">
<label for="game_url" class="form-label">{{ _('Shop URL') }}</label>
<input type="url" id="game_url" name="url" class="form-control" value="{{ request.form.url or '' }}">
2025-05-02 12:49:43 +00:00
</div>
<!-- Notes -->
2025-05-02 12:49:43 +00:00
<div class="col-12">
<label for="game_notes" class="form-label">{{ _('Notes') }}</label>
<textarea id="game_notes" name="notes" class="form-control" rows="3">{{ request.form.notes or '' }}</textarea>
2025-05-02 12:49:43 +00:00
</div>
<!-- Buttons -->
2025-05-02 12:49:43 +00:00
<div class="col-12">
<button type="submit" class="btn btn-primary">{{ _('Save') }}</button>
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary ms-2">{{ _('Cancel') }}</a>
</div>
</div>
</form>
2025-04-21 10:45:06 +00:00
</div>
{% endblock %}
HTML_END
2025-04-26 12:32:07 +00:00
# Import/Export Templates
cat <<HTML_END > templates/import.html
2025-04-21 10:45:06 +00:00
{% extends "base.html" %}
{% block content %}
2025-04-26 12:32:07 +00:00
<div class="card p-4 shadow-sm">
<h2 class="mb-4">{{ _('Import Games') }}</h2>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
2025-04-29 13:19:59 +00:00
<label class="form-label">{{ _('Select CSV file') }}</label>
2025-04-26 12:32:07 +00:00
<input type="file" name="file" class="form-control" accept=".csv" required>
2025-04-21 10:45:06 +00:00
</div>
2025-04-29 13:19:59 +00:00
<button type="submit" class="btn btn-success">{{ _('Import') }}</button>
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">{{ _('Cancel') }}</a>
2025-04-26 12:32:07 +00:00
</form>
2025-04-21 10:45:06 +00:00
</div>
{% endblock %}
HTML_END
2025-04-26 12:32:07 +00:00
# Redeem Template
cat <<'HTML_END' > templates/redeem.html
2025-04-21 10:45:06 +00:00
{% extends "base.html" %}
{% block content %}
2025-04-26 12:32:07 +00:00
<div class="container mt-5">
<div class="card shadow-lg">
<div class="row g-0">
{% if game.steam_appid %}
<div class="col-md-4">
<img src="https://cdn.cloudflare.steamstatic.com/steam/apps/{{ game.steam_appid }}/header.jpg"
class="img-fluid rounded-start" alt="Game Cover">
</div>
{% endif %}
<div class="col-md-8">
<div class="card-body">
<h1 class="card-title mb-4">{{ game.name }}</h1>
<div class="alert alert-success">
<h4>{{ _('Your Key:') }}</h4>
<code class="fs-3">{{ game.steam_key }}</code>
2025-04-21 10:45:06 +00:00
</div>
2025-04-26 12:32:07 +00:00
<a href="{{ platform_link }}{{ game.steam_key }}"
2025-05-09 09:31:50 +00:00
class="btn btn-primary btn-lg mb-3"
target="_blank">
{{ _('Redeem now on') }} {{ platform_label }}
2025-04-26 12:32:07 +00:00
</a>
<div class="mt-4 text-muted">
<small>
{{ _('This page will expire in') }}
<span id="expiry-countdown" class="fw-bold"></span>
</small>
<div class="progress mt-2" style="height: 8px;">
<div id="expiry-bar"
class="progress-bar bg-danger"
role="progressbar"
style="width: 100%">
</div>
</div>
2025-04-21 10:45:06 +00:00
</div>
2025-04-26 12:32:07 +00:00
</div>
2025-04-21 10:45:06 +00:00
</div>
</div>
</div>
</div>
2025-04-26 12:32:07 +00:00
<script>
const totalDuration = {{ redeem_token.total_hours * 3600 * 1000 }}; // Gesamtdauer in Millisekunden
const expires = {{ expires_timestamp }};
2025-04-26 12:32:07 +00:00
const countdownEl = document.getElementById('expiry-countdown');
const progressBar = document.getElementById('expiry-bar');
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
2025-04-26 12:32:07 +00:00
function formatTime(unit) {
return unit < 10 ? `0${unit}` : unit;
}
function updateProgressBar(percentage) {
// Alle Farbklassen entfernen
progressBar.classList.remove('bg-success', 'bg-warning', 'bg-danger');
if (percentage > 75) {
progressBar.classList.add('bg-success');
} else if (percentage > 25) {
progressBar.classList.add('bg-warning');
} else {
progressBar.classList.add('bg-danger');
}
}
function updateCountdown() {
const now = Date.now();
const remaining = expires - now;
const percent = (remaining / totalDuration) * 100;
if (remaining < 0) {
countdownEl.innerHTML = "EXPIRED";
progressBar.style.width = "0%";
clearInterval(timer);
setTimeout(() => window.location.reload(), 5000);
return;
}
const hours = Math.floor(remaining / (1000 * 60 * 60));
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((remaining % (1000 * 60)) / 1000);
countdownEl.innerHTML = `${formatTime(hours)}h ${formatTime(minutes)}m ${formatTime(seconds)}s`;
progressBar.style.width = `${percent}%`;
updateProgressBar(percent);
}
2025-04-29 12:46:55 +00:00
// run countdown
2025-04-26 12:32:07 +00:00
updateCountdown();
const timer = setInterval(updateCountdown, 1000);
</script>
2025-04-21 10:45:06 +00:00
{% endblock %}
HTML_END
# Game Details Templates
cat <<HTML_END > templates/game_details.html
{% extends "base.html" %}
{% block content %}
<div class="card shadow-sm">
<div class="card-body">
<h1>{{ game.name }}</h1>
<div class="row">
<!-- Bild und Basis-Infos -->
<div class="col-md-4">
{% if game.steam_appid %}
<img src="https://cdn.cloudflare.steamstatic.com/steam/apps/{{ game.steam_appid }}/header.jpg"
class="img-fluid rounded mb-3"
alt="{{ game.name }} Cover"
loading="lazy">
{% endif %}
</div>
<!-- Details -->
<div class="col-md-8">
<dl class="row">
<dt class="col-sm-3">{{ _('Status') }}</dt>
<dd class="col-sm-9">
{% if game.status == 'nicht eingelöst' %}
<span class="badge bg-warning text-dark">{{ _('Not redeemed') }}</span>
{% elif game.status == 'geschenkt' %}
<span class="badge bg-success">{{ _('Gifted') }}</span>
{% elif game.status == 'eingelöst' %}
<span class="badge bg-secondary">{{ _('Redeemed') }}</span>
{% endif %}
</dd>
<dt class="col-sm-3">{{ _('Release Date') }}</dt>
<dd class="col-sm-9">{{ game.release_date|strftime('%d.%m.%Y') if game.release_date else 'N/A' }}</dd>
<dt class="col-sm-3">{{ _('Current Price') }}</dt>
<dd class="col-sm-9">{{ "%.2f €"|format(game.current_price) if game.current_price else 'N/A' }}</dd>
</dl>
<a href="{{ url_for('edit_game', game_id=game.id) }}" class="btn btn-primary">
{{ _('Edit') }}
</a>
</div>
</div>
{% set lang = session.get('lang', 'en') %}
{% set desc = getattr(game, 'steam_description_' + lang) %}
{% if desc %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">{{ _('Game Description') }}</div>
<div class="card-body">
{{ desc|safe }}
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
HTML_END
2025-04-26 12:32:07 +00:00
# Footer Template
cat <<HTML_END > templates/footer.html
<footer class="mt-5 py-4 bg-body-tertiary border-top">
<div class="container text-center small text-muted">
<div class="mb-2">
<strong>Game Key Manager</strong> &mdash; is done by nocci
</div>
<div class="mb-2">
<a href="https://git.nocci.it/nocci/GiftGamesDB" target="_blank" rel="noopener">
2025-05-02 12:07:56 +00:00
<img src="{{ url_for('static', filename='forgejo.webp') }}" alt="forgejo" width="20" style="vertical-align:middle;margin-right:4px;">
2025-04-26 12:32:07 +00:00
find the source code on my Forgejo
</a>
</div>
<div>
<span>feel free to donate - if you can affort it:</span>
<a href="https://ko-fi.com/nocci" target="_blank" rel="noopener">Ko-fi</a> &middot;
<a href="https://liberapay.com/nocci" target="_blank" rel="noopener">Liberapay</a>
</div>
</div>
</footer>
HTML_END
# Admin interface
cat <<HTML_END > templates/admin_users.html
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>{{ _('User Management') }}</h2>
<table class="table">
<thead>
<tr>
<th>{{ _('Username') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
{{ user.username }}
{% if user.is_admin %}<span class="badge bg-primary">Admin</span>{% endif %}
</td>
<td>
{% if user.id != current_user.id %}
<form method="POST" action="{{ url_for('admin_delete_user', user_id=user.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger btn-sm">{{ _('Delete') }}</button>
</form>
<form method="POST" action="{{ url_for('admin_reset_password', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-warning">{{ _('Reset Password') }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
HTML_END
# Admin Audits
cat <<HTML_END > templates/admin_audit_logs.html
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>{{ _('Audit Logs') }}</h2>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{{ _('Timestamp') }}</th>
<th>{{ _('User') }}</th>
<th>{{ _('Action') }}</th>
<th>{{ _('Details') }}</th>
</tr>
</thead>
<tbody>
{% for log in logs.items %}
<tr>
<td>{{ log.timestamp|strftime('%d.%m.%Y %H:%M') }}</td>
<td>{{ log.user.username if log.user else 'System' }}</td>
<td>{{ log.action }}</td>
<td>{{ log.details|default('', true) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if logs.pages > 1 %}
<nav aria-label="Page navigation">
<ul class="pagination">
{% if logs.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin_audit_logs', page=logs.prev_num) }}">{{ _('Previous') }}</a>
</li>
{% endif %}
{% for page_num in logs.iter_pages() %}
<li class="page-item {% if page_num == logs.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('admin_audit_logs', page=page_num) }}">{{ page_num }}</a>
</li>
{% endfor %}
{% if logs.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin_audit_logs', page=logs.next_num) }}">{{ _('Next') }}</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{% endblock %}
HTML_END
# Error Sites
cat <<HTML_END > templates/403.html
{% extends "base.html" %}
{% block content %}
<div class="d-flex flex-column align-items-center justify-content-center" style="min-height:60vh;">
<div class="text-center">
<img src="{{ url_for('static', filename='logo.webp') }}"
alt="Forbidden"
class="img-fluid rounded shadow mb-4"
style="max-width: 160px;">
<h1 class="display-3 fw-bold text-danger mb-3">403</h1>
<h2 class="mb-4">{{ _('Access Forbidden') }}</h2>
<p class="lead mb-4">
<span class="d-block mb-2">{{ _('Sorry, you are not allowed to access this page.') }}</span>
<span class="text-muted">({{ _('Registration is currently disabled.') }})</span>
</p>
<a href="{{ url_for('index') }}" class="btn btn-lg btn-primary shadow">
🏠 {{ _('Back to Home') }}
</a>
<div class="mt-4 text-muted">
<small>
<span>Sorry, you haven't unlocked this area yet. Grind some more XP or check your DLC entitlements.<br>Maybe try again after the next patch?</span>
</small>
</div>
</div>
</div>
{% endblock %}
HTML_END
2025-04-21 10:45:06 +00:00
cat <<HTML_END > templates/404.html
{% extends "base.html" %}
{% block content %}
<div class="d-flex flex-column align-items-center justify-content-center" style="min-height:60vh;">
<div class="text-center">
<img src="{{ url_for('static', filename='logo.webp') }}"
alt="Forbidden"
class="img-fluid rounded shadow mb-4"
style="max-width: 160px;">
<h1 class="display-3 fw-bold text-danger mb-3">404</h1>
<h2 class="mb-4">{{ _('Access Forbidden') }}</h2>
<p class="lead mb-4">
<span class="d-block mb-2">{{ _('Sorry, you are not allowed to access this page.') }}</span>
<span class="text-muted">({{ _('Registration is currently disabled.') }})</span>
</p>
<a href="{{ url_for('index') }}" class="btn btn-lg btn-primary shadow">
🏠 {{ _('Back to Home') }}
</a>
<div class="mt-4 text-muted">
<small>
<span>Sorry, you haven't unlocked this area yet. Grind some more XP or check your DLC entitlements.<br>Maybe try again after the next patch?</span>
</small>
</div>
</div>
</div>
{% endblock %}
HTML_END
2025-04-21 10:45:06 +00:00
# CSS
cat <<CSS_END > static/style.css
:root {
--bs-body-bg: #ffffff;
--bs-body-color: #212529;
}
[data-bs-theme="dark"] {
--bs-body-bg: #1a1a1a;
--bs-body-color: #f8f9fa;
--bs-border-color: #495057;
}
[data-bs-theme="dark"] .table {
--bs-table-bg: #212529;
--bs-table-color: #fff;
--bs-table-border-color: #495057;
}
[data-bs-theme="dark"] .card {
background-color: #2b3035;
border-color: var(--bs-border-color);
}
[data-bs-theme="dark"] .navbar {
background-color: #212529 !important;
}
body {
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
transition: all 0.3s ease;
}
.font-monospace {
font-family: Monaco, Consolas, "Courier New", monospace;
}
.badge {
font-size: 0.9em;
font-weight: 500;
}
2025-04-26 12:32:07 +00:00
#expiry-countdown {
font-weight: 600;
letter-spacing: 0.05em;
color: #dc3545;
transition: color 0.3s ease;
}
2025-04-21 10:45:06 +00:00
2025-04-26 12:32:07 +00:00
[data-bs-theme="dark"] #expiry-countdown {
color: #ff6b6b;
}
2025-04-21 10:45:06 +00:00
2025-04-29 12:46:55 +00:00
/* Progressbar-Animations */
2025-04-26 12:32:07 +00:00
#expiry-bar {
transition: width 1s linear, background-color 0.5s ease;
}
.bg-success { background-color: #198754 !important; }
.bg-warning { background-color: #ffc107 !important; }
.bg-danger { background-color: #dc3545 !important; }
.progress-bar {
transition: width 1s linear, background-color 0.3s ease;
}
.table-pdf {
font-size: 0.8em;
}
.table-pdf td, .table-pdf th {
padding: 4px 8px;
}
2025-05-02 10:43:28 +00:00
2025-05-02 12:49:43 +00:00
.badge.bg-warning {
background-color: #ffcc00 !important;
color: #222 !important;
}
.badge.bg-success {
background-color: #198754 !important;
color: #fff !important;
}
2025-05-02 10:43:28 +00:00
.game-cover {
width: 368px;
height: 172px;
max-width: 100%;
max-height: 35vw;
object-fit: contain;
background: #222;
border-radius: 8px;
display: block;
margin: 0 auto;
transition: width 0.2s, height 0.2s;
}
2025-05-02 12:07:56 +00:00
/* Responsive Cover Images */
.game-cover {
width: 368px;
height: 172px;
object-fit: contain;
background: #222;
border-radius: 6px;
}
2025-05-02 10:43:28 +00:00
@media (max-width: 1200px) {
.game-cover {
width: 260px;
height: 122px;
}
}
2025-05-02 12:07:56 +00:00
@media (max-width: 992px) {
2025-05-02 10:43:28 +00:00
.game-cover {
width: 180px;
height: 84px;
}
}
2025-05-02 12:07:56 +00:00
@media (max-width: 768px) {
2025-05-02 10:43:28 +00:00
.game-cover {
width: 120px;
height: 56px;
}
}
2025-05-02 12:07:56 +00:00
@media (max-width: 576px) {
2025-05-02 10:43:28 +00:00
.game-cover {
width: 90px;
height: 42px;
}
}
2025-05-02 12:07:56 +00:00
/* Accessibility Improvements */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
2025-05-02 10:43:28 +00:00
}
.price-value {
font-size: 1.2em;
font-weight: 400;
margin-top: 2px;
}
.navbar-nav .nav-link {
white-space: nowrap;
}
@media (max-width: 991.98px) {
.navbar-nav {
flex-direction: column !important;
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; }
2025-04-26 12:32:07 +00:00
CSS_END
# directories and permissions
mkdir -p ../data
chmod -R a+rwX ../data
find ../data -type d -exec chmod 775 {} \;
find ../data -type f -exec chmod 664 {} \;
# entrypoint.sh script
cat <<SCRIPT_END > entrypoint.sh
#!/bin/bash
# Debug-Output
echo "🔄 DEBUGPY-Value: '$DEBUGPY'"
echo "🔄 FLASK_DEBUG-Value: '$FLASK_DEBUG'"
# 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
else
echo "🚀 Starting in PRODUCTION mode..."
exec gunicorn -b 0.0.0.0:5000 app:app
fi
SCRIPT_END
chmod +x entrypoint.sh
# create translate.sh and run it
cat <<'SCRIPT_END' > ../translate.sh
#!/bin/bash
set -e
APP_DIR="steam-gift-manager"
TRANSLATION_DIR="$APP_DIR/translations"
LANGS=("de" "en")
# check jq
if ! command -v jq &>/dev/null; then
echo "❌ jq is required. Install with: sudo apt-get install jq"
exit 1
fi
echo -e "\n\033[1;32m✅ Extracting translations...\033[0m"
# 1. create json files
mkdir -p "$TRANSLATION_DIR"
for lang in "${LANGS[@]}"; do
file="$TRANSLATION_DIR/$lang.json"
[ -f "$file" ] || echo "{}" > "$file"
done
# 2. extract all strings
STRINGS=$(grep -rhoP "_\(\s*['\"]((?:[^']|'[^'])*?)['\"]\s*[,)]" \
"$APP_DIR/templates" "$APP_DIR/app.py" | \
sed -E "s/_\(\s*['\"](.+?)['\"]\s*[,)]/\1/" | sort | uniq)
# 3. put da keys in da json
for lang in "${LANGS[@]}"; do
file="$TRANSLATION_DIR/$lang.json"
tmp="$file.tmp"
jq --argjson keys "$(echo "$STRINGS" | jq -R . | jq -s .)" \
'reduce $keys[] as $k (.; .[$k] = (.[$k] // ""))' "$file" > "$tmp"
mv "$tmp" "$file"
done
echo -e "\n\033[1;32m✅ Done! Translation keys added.\033[0m"
SCRIPT_END
chmod +x ../translate.sh
# EXECUTE IMMEDIATELY to create basic JSONs
cd ..
./translate.sh
cd $PROJECT_DIR
# Dockerfile
cat <<'DOCKER_END' > Dockerfile
FROM python:3.10-slim
SHELL ["/bin/bash", "-c"]
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
wget \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y locales && \
sed -i '/de_DE.UTF-8/s/^# //' /etc/locale.gen && \
locale-gen
ENV LC_ALL=de_DE.UTF-8 LANG=de_DE.UTF-8
RUN mkdir -p /app/data && \
chown -R 1000:1000 /app/data
ENV TZ=${TZ}
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ARG UID=1000
ARG GID=1000
RUN groupadd -g ${GID} appuser && \
useradd -l -o -u ${UID} -g appuser -m appuser && \
mkdir -p /app && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 5000 5678
ENTRYPOINT ["/app/entrypoint.sh"]
DOCKER_END
# create docker-compose.yml
cat <<COMPOSE_END > docker-compose.yml
services:
redis:
image: redis:alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- app-network
steam-manager:
build:
context: .
args:
- UID=${UID:-1000}
- GID=${GID:-1000}
ports:
- "5000:5000"
- "5678:5678"
env_file:
- .env
environment:
- REDIS_URL=redis://redis:6379/0
volumes:
- ../data:/app/data
- ./translations:/app/translations:rw
- ./static:/app/static:rw
user: "${UID:-1000}:${GID:-1000}"
restart: unless-stopped
command: ["/app/entrypoint.sh"]
networks:
- app-network
depends_on:
- redis
volumes:
redis_data:
networks:
app-network:
driver: bridge
COMPOSE_END
cat <<'SCRIPT_END' > ../upgrade.sh
#!/bin/bash
set -e
# Set the working directory to the project directory
cd "$(dirname "$0")/steam-gift-manager"
# set FLASK_APP, if needed
export FLASK_APP=app.py
# Initialize migrations, if not yet available
if [ ! -d migrations ]; then
echo "Starting Flask-Migrate..."
docker-compose exec steam-manager flask db init
fi
# Create migration (only if models have changed)
docker-compose exec steam-manager flask db migrate -m "Automatic Migration"
# Apply migration
docker-compose exec steam-manager flask db upgrade
echo "✅ Database migration completed!"
SCRIPT_END
chmod +x ../upgrade.sh
# Manifest for PWA
cat <<MANIFEST_END > static/manifest.json
{
"id": "/",
"name": "Game Key Manager",
"short_name": "GameKeys",
"start_url": "/",
"display": "standalone",
"background_color": "#212529",
"theme_color": "#212529",
"description": "Manage Steam/GOG keys easily!",
"orientation": "any",
"launch_handler": {
"client_mode": "navigate-existing"
},
"icons": [
{
"src": "/static/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/webp",
"purpose": "any"
},
{
"src": "/static/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#3f3a3a",
"display": "standalone"
}
MANIFEST_END
# Service Worker
cat <<SW_END > static/serviceworker.js
const CACHE_NAME = 'game-key-manager-v2';
const ASSETS = [
'/',
'/static/style.css',
'/static/logo.webp',
'/static/web-app-manifest-512x512.png',
'/static/web-app-manifest-192x192.png',
'/static/logo_small.webp',
'/static/gog_logo.webp',
'/static/forgejo.webp'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(ASSETS))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => cachedResponse || fetch(event.request))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
))
);
});
SW_END
# Download German Translation from my server
cd "$TRANSLATIONS_DIR"
read -p "Do you want to download German translations from the dev-server (could cause trouble, but normally it is safe)? [y/N]: " download_de
if [[ "$download_de" =~ ^[YyJj]$ ]]; then
2025-05-09 15:06:26 +00:00
wget -O de.json "https://assets.skynet.li/de.json" > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo "✅ German translations downloaded successfully."
else
echo "❌ Download failed. Keeping existing de.json."
fi
else
echo "⏩ Skipped downloading German translations."
fi
2025-04-26 12:32:07 +00:00
echo -e "\n\033[1;32m✅ Setup done! Seems to be okay!\033[0m"
echo -e "Have a look in your .env"
echo -e "nano .env"
2025-05-01 11:23:46 +00:00
echo -e "\n\033[1;32m✅ After you are done start the system with:\033[0m"
2025-04-21 10:45:06 +00:00
echo -e "cd steam-gift-manager"
echo -e "docker-compose build --no-cache && docker-compose up -d"
echo -e "\n${GREEN}✅ JSON-based translations!${NC}"
echo -e "you can edit them here:"
echo -e " - translations/de.json"
echo -e " - translations/en.json"
echo -e "Enter your Apprise URLs in .env at APPRISE_URLS (e.g. for Pushover, Gotify, Matrix etc.)"
echo -e "You can put your IsThereAnyDeal API Key in there, too"
2025-04-26 12:32:07 +00:00
echo -e "\nAfter any change in you configuration, .env or even translations:"
echo -e "cd steam-gift-manager"
echo -e "docker-compose down && docker-compose up -d --build"
echo -e "\n\033[1;32m❗ Great - if you are updating/upgrading - visit my git for more details ❗\033[0m"