diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index b1b9d3a..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Python: Remote Attach", - "type": "python", - "request": "attach", - "connect": { - "host": "192.168.10.31", - "port": 5678 - }, - "pathMappings": [ - { - "localRoot": "${workspaceFolder}", - "remoteRoot": "." - } - ], - "justMyCode": true - } - ] -} \ No newline at end of file diff --git a/app.py b/app.py deleted file mode 100644 index e69de29..0000000 diff --git a/setup.sh b/setup.sh index ee3d8e7..0ef74d5 100644 --- a/setup.sh +++ b/setup.sh @@ -87,15 +87,6 @@ chmod -R a+rwX "$TRANSLATIONS_DIR" "$DATA_DIR" cd $PROJECT_DIR -## UID/GID-Logic -#if [ "$(id -u)" -eq 0 ]; then -# export UID=1000 -# export GID=1000 -#else -# export UID=$(id -u) -# export GID=$(id -g) -#fi - # 2. requirements.txt cat < requirements.txt flask @@ -114,10 +105,6 @@ requests pillow gunicorn apprise -debugpy -pytz -Flask-Session -redis EOL # 3. .env Datei in Parent-Folder @@ -156,50 +143,24 @@ APPRISE_URLS="" #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) -DEBUGPY=0 EOL cd $PROJECT_DIR # 4. app.py (the main app) cat <<'PYTHON_END' > app.py -import os, time -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -import pytz +import os import warnings from sqlalchemy.exc import LegacyAPIWarning warnings.simplefilter("ignore", category=LegacyAPIWarning) -from flask import ( - Flask, - render_template, - request, - redirect, - url_for, - flash, - session, - abort, - send_file, - jsonify, - Markup, - make_response, - abort -) +from flask import Flask, render_template, request, redirect, url_for, flash, make_response, session, abort, send_file, jsonify from flask_sqlalchemy import SQLAlchemy -from flask_session import Session from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user from werkzeug.security import generate_password_hash, check_password_hash +from datetime import datetime, timedelta from flask_wtf import CSRFProtect from flask import abort from flask import request, redirect -from flask_wtf import FlaskForm -from flask_wtf.csrf import CSRFProtect -from wtforms import StringField, SelectField, TextAreaField, validators import io import warnings import re @@ -211,10 +172,9 @@ from dotenv import load_dotenv load_dotenv(override=True) from sqlalchemy.exc import IntegrityError from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore import atexit from flask_migrate import Migrate -from sqlalchemy import MetaData, event, UniqueConstraint +from sqlalchemy import MetaData from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import A4, landscape, letter from reportlab.platypus import ( @@ -231,41 +191,15 @@ from reportlab.lib.utils import ImageReader from reportlab.lib.units import cm, inch, mm from io import BytesIO import reportlab.lib -import traceback import logging -logging.basicConfig(level=logging.INFO) -logging.getLogger('apscheduler').setLevel(logging.DEBUG) -from sqlalchemy.engine import Engine -import sqlite3 -from sqlalchemy.orm import joinedload -from functools import wraps -from flask_session import Session -from redis import Redis - -@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() - -TZ = os.getenv('TZ', 'UTC') -os.environ['TZ'] = TZ +logging.basicConfig() app = Flask(__name__) -# Auf UNIX-Systemen (Linux, Docker) wirksam machen -try: - time.tzset() -except AttributeError: - pass # tzset gibt es auf Windows nicht -local_tz = pytz.timezone(TZ) - # Load Languages import os import json - -TRANSLATION_DIR = os.path.join(os.getcwd(), 'translations') +TRANSLATION_DIR = os.path.join(os.path.dirname(__file__), 'translations') SUPPORTED_LANGUAGES = ['de', 'en'] TRANSLATIONS = {} @@ -273,37 +207,23 @@ for lang in SUPPORTED_LANGUAGES: try: 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") except Exception: - print(f"❌ Failed loading {lang}.json: {str(e)}") TRANSLATIONS[lang] = {} def translate(key, lang=None, **kwargs): - lang = lang or session.get('lang', 'en') - fallback_lang = app.config.get('DEFAULT_LANGUAGE', 'en') - - translations = TRANSLATIONS.get(lang, {}) - fallback_translations = TRANSLATIONS.get(fallback_lang, {}) - - value = translations.get(key) or fallback_translations.get(key) or key - return value.format(**kwargs) if isinstance(value, str) else value + if not lang: + lang = session.get('lang', 'en') + value = TRANSLATIONS.get(lang, {}).get(key) + if value is None and lang != 'en': + value = TRANSLATIONS.get('en', {}).get(key, key) + else: + value = value or key + return value.format(**kwargs) if kwargs and 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 - - csrf = CSRFProtect(app) convention = { @@ -322,38 +242,19 @@ load_dotenv(override=True) # App-Configuration app.config.update( - # WICHTIGSTE EINSTELLUNGEN SECRET_KEY=os.getenv('SECRET_KEY'), - SQLALCHEMY_DATABASE_URI = 'sqlite:////app/data/games.db', - SQLALCHEMY_TRACK_MODIFICATIONS = False, - - # SESSION-HANDLING (Produktion: Redis verwenden!) - 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), - - # 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' + SQLALCHEMY_DATABASE_URI='sqlite:////app/data/games.db', + SQLALCHEMY_TRACK_MODIFICATIONS=False, + SESSION_COOKIE_SECURE=os.getenv('SESSION_COOKIE_SECURE', 'False') == 'True', + SESSION_COOKIE_SAMESITE='Lax', + PERMANENT_SESSION_LIFETIME=timedelta(days=30), + SESSION_REFRESH_EACH_REQUEST=False, + WTF_CSRF_ENABLED=os.getenv('CSRF_ENABLED', 'True') == 'True', + 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') == 'True' ) - -Session(app) - interval_hours = int(os.getenv('CHECK_EXPIRING_KEYS_INTERVAL_HOURS', 12)) # Initialisation @@ -368,138 +269,51 @@ app.logger.setLevel(logging.INFO) @app.before_request -def debug_translations(): - app.logger.debug(f"Aktuelle Sprache: {session.get('lang')}") - app.logger.debug(f"Übersetzungskeys: {list(TRANSLATIONS.get(session.get('lang', 'en'), {}).keys())}") def enforce_https(): if os.getenv('FORCE_HTTPS', 'False').lower() == 'true': if request.headers.get('X-Forwarded-Proto', 'http') != 'https' and not request.is_secure: url = request.url.replace('http://', 'https://', 1) app.logger.info(f"Redirecting to HTTPS: {url}") return redirect(url, code=301) -def check_translations(): - app.logger.debug(f"Available translations: {TRANSLATIONS}") - app.logger.debug(f"Current language: {session.get('lang', 'en')}") @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': str(local_tz) - } - -@app.template_filter('strftime') -def _jinja2_filter_datetime(date, fmt='%d.%m.%Y'): - if date is None: - return '' - return date.strftime(fmt) - +def inject_template_vars(): + def _(key, **kwargs): + lang = session.get('lang', 'en') + return translate(key, lang, **kwargs) + theme = request.cookies.get('theme', 'light') + return dict(_=_, theme=theme) # DB Models -class User(UserMixin, db.Model): +class User(db.Model, UserMixin): __tablename__ = 'users' - id = db.Column(db.Integer, primary_key=True) 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 - ) - + games = db.relationship('Game', back_populates='owner', lazy=True) class Game(db.Model): - __tablename__ = 'games' - __table_args__ = ( - UniqueConstraint('steam_key', 'user_id', name='uq_steam_key_user'), - ) - id = db.Column(db.Integer, primary_key=True) + owner = db.relationship('User', back_populates='games') name = db.Column(db.String(100), nullable=False) steam_key = db.Column(db.String(100), nullable=False, unique=True) 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)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) redeem_date = db.Column(db.DateTime) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) steam_appid = db.Column(db.String(20)) - platform = db.Column(db.String(50), default='pc') - - # with users.id - 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 - ) class RedeemToken(db.Model): - __tablename__ = 'redeem_tokens' - id = db.Column(db.Integer, primary_key=True) token = db.Column(db.String(17), unique=True, nullable=False) + game_id = db.Column(db.Integer, db.ForeignKey('game.id'), nullable=False) expires = db.Column(db.DateTime, nullable=False) + used = db.Column(db.Boolean, default=False) 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 = [ - ('pc', 'PC'), - ('xbox', 'XBox'), - ('playstation', 'PlayStation'), - ('switch', 'Nintendo Switch'), - ('other', 'Andere') -] - -STATUS_CHOICES = [ - ('nicht eingelöst', 'Nicht eingelöst'), - ('eingelöst', 'Eingelöst'), - ('geschenkt', 'Geschenkt') -] - with app.app_context(): db.create_all() @@ -538,13 +352,13 @@ def index(): def set_lang(lang): if lang in SUPPORTED_LANGUAGES: session['lang'] = lang - session.permanent = True return redirect(request.referrer or url_for('index')) @app.route('/set-theme/') def set_theme(theme): resp = make_response('', 204) - resp.set_cookie('theme', theme, max_age=60*60*24*365) # 1 Jahr Gültigkeit + # Von 'dark_mode' zu 'theme' ändern + resp.set_cookie('theme', theme, max_age=60*60*24*365) return resp @app.route('/login', methods=['GET', 'POST']) @@ -558,38 +372,29 @@ def login(): login_user(user) return redirect(url_for('index')) - flash(translate('Invalid credentials', session.get('lang', 'en')), 'danger') + flash(_('Invalid credentials'), 'danger') return render_template('login.html') @app.route('/register', methods=['GET', 'POST']) def register(): if not app.config['REGISTRATION_ENABLED']: - abort(403) - + flash(_('No new registrations. They are deactivated!'), 'danger') + return redirect(url_for('login')) + 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') + password = generate_password_hash(request.form['password']) + + if User.query.filter_by(username=username).first(): + flash(_('Username already exists'), 'danger') 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 - ) - + + new_user = User(username=username, password=password) db.session.add(new_user) db.session.commit() login_user(new_user) - flash(translate('Registration successful'), 'success') return redirect(url_for('index')) - + return render_template('register.html') @app.route('/logout') @@ -607,16 +412,16 @@ def change_password(): confirm_password = request.form['confirm_password'] if not check_password_hash(current_user.password, current_password): - flash(translate('Current passwort is wrong'), 'danger') + flash(_('Current passwort is wrong'), 'danger') return redirect(url_for('change_password')) if new_password != confirm_password: - flash(translate('New Passwords are not matching'), 'danger') + flash(_('New Passwords are not matching'), 'danger') return redirect(url_for('change_password')) current_user.password = generate_password_hash(new_password) db.session.commit() - flash(translate('Password changed successfully', session.get('lang', 'en')), 'success') + flash(_('Password changed successfully'), 'success') return redirect(url_for('index')) return render_template('change_password.html') @@ -631,15 +436,10 @@ def add_game(): 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')) - + new_game = Game( name=request.form['name'], - steam_key=steam_key, + steam_key=request.form['steam_key'], status=request.form['status'], recipient=request.form.get('recipient', ''), notes=request.form.get('notes', ''), @@ -651,119 +451,83 @@ def add_game(): db.session.add(new_game) db.session.commit() - flash(translate('Game added successfully!'), 'success') + flash(_('Game added successfully!'), 'success') return redirect(url_for('index')) - except IntegrityError as e: + except IntegrityError: 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') - + flash(_('Steam Key already exists!'), 'danger') except Exception as e: db.session.rollback() - flash(translate('Error: %(error)s', error=str(e)), 'error') + flash(_('Error: ') + str(e), 'danger') - return render_template( - 'add_game.html', - platforms=PLATFORM_CHOICES, - statuses=STATUS_CHOICES - ) - + return render_template('add_game.html') @app.route('/edit/', 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 + game = db.session.get(Game, game_id) + if not game or game.owner != current_user: + abort(404) + + if not game or game.owner != current_user: + abort(403) + + active_redeem = RedeemToken.query.filter( + RedeemToken.game_id == game_id, + RedeemToken.expires > datetime.utcnow() + ).first() + + redeem_url = url_for('redeem_page', token=active_redeem.token, _external=True) if active_redeem else None if request.method == 'POST': try: - # Validierung - 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)) - - # Dublettenprüfung - existing = Game.query.filter( - Game.steam_key == request.form['steam_key'], - Game.id != game.id - ).first() - if existing: - flash(translate('Steam Key already exists'), 'error') - return redirect(url_for('edit_game', game_id=game_id)) - - # Felder aktualisieren + url = request.form.get('url', '') + steam_appid = request.form.get('steam_appid', '').strip() + + if not steam_appid: + steam_appid = extract_steam_appid(url) + game.name = request.form['name'] game.steam_key = request.form['steam_key'] game.status = request.form['status'] - game.platform = request.form.get('platform', 'pc') 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', '')) + game.url = url + game.steam_appid = steam_appid + game.redeem_date = datetime.strptime(request.form['redeem_date'], '%Y-%m-%d') if request.form['redeem_date'] else None - # Zeitzonen-korrekte Umwandlung - game.redeem_date_local = ( - game.redeem_date.astimezone(local_tz) - if game.redeem_date - else None - ) - - # Token-Logik - if game.status == 'geschenkt': - # Vorhandene Tokens löschen - RedeemToken.query.filter_by(game_id=game.id).delete() - - # Neuen Token generieren - 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) - db.session.commit() - flash(translate('Changes saved successfully'), 'success') + flash(_('Changes saved!'), 'success') 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') # Platzhalter korrigiert + 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') # Platzhalter korrigiert - - 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 '' - ) + flash(_('Error: ') + str(e), 'danger') + + return render_template('edit_game.html', + game=game, + redeem_url=redeem_url, + active_redeem=active_redeem, + redeem_date=game.redeem_date.strftime('%Y-%m-%d') if game.redeem_date else '') @app.route('/delete/', 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') + game = db.session.get(Game, game_id) + if not game or game.owner != current_user: + abort(404) + + if game.owner != current_user: + abort(403) + + try: + db.session.delete(game) + db.session.commit() + except Exception as e: + db.session.rollback() + return redirect(url_for('index')) @@ -822,10 +586,7 @@ def export_pdf(): img_height = 2*cm # Titel - elements.append(Paragraph( - translate("Game List (without Keys)", lang=session.get('lang', 'en')), - styles['Title'] - )) + elements.append(Paragraph(_("Game List (without Keys)"), styles['Title'])) elements.append(Spacer(1, 12)) # Tabellenkopf @@ -925,60 +686,59 @@ def import_games(): db.session.commit() - flash(translate("new_games_imported", new=new_games, dup=duplicates), 'success') + flash(_('%(new)d new games imported, %(dup)d skipped duplicates', new=new_games, dup=duplicates), 'success') except Exception as e: db.session.rollback() - flash(translate('Import error: {error}', error=str(e)), 'danger') + flash(_('Import error: %(error)s', error=str(e)), 'danger') return redirect(url_for('index')) - flash(translate('Please upload a valid CSV file.'), 'danger') + flash(_('Please upload a valid CSV file.'), 'danger') return render_template('import.html') - @app.route('/generate_redeem/', 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 - + game = db.session.get(Game, game_id) + if not game or game.owner != current_user: + abort(403) + + if game.owner != current_user or game.status != 'verschenkt': + abort(403) + try: - RedeemToken.query.filter_by(game_id=game_id).delete() token = secrets.token_urlsafe(12)[:17] - expires = datetime.now(local_tz) + timedelta(hours=24) + expires = datetime.utcnow() + timedelta(hours=24) + total_hours = 24 + + RedeemToken.query.filter_by(game_id=game_id).delete() + 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: {url}', - url=redeem_url - ) - return jsonify({'url': redeem_url, 'message': message}) + + redeem_url = url_for('redeem_page', token=token, _external=True) + return jsonify({'url': redeem_url}) + except Exception as e: - db.session.rollback() + app.logger.error(f"Redeem error: {str(e)}") return jsonify({'error': str(e)}), 500 - -@app.route('/redeem/', endpoint='redeem') +@app.route('/redeem/') def redeem_page(token): redeem_token = RedeemToken.query.filter_by(token=token).first() if not redeem_token: abort(404) - - # Zeit in UTC umwandeln - expires_utc = redeem_token.expires.astimezone(pytz.UTC) - - if datetime.now(pytz.UTC) > expires_utc: + if redeem_token.expires < datetime.utcnow(): db.session.delete(redeem_token) db.session.commit() abort(404) @@ -990,49 +750,8 @@ def redeem_page(token): return render_template('redeem.html', game=game, redeem_token=redeem_token, - expires_timestamp=int(expires_utc.timestamp() * 1000), # Millisekunden platform_link='https://store.steampowered.com/account/registerkey?key=' if game.steam_appid else 'https://www.gog.com/redeem') -@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/', 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() - flash(translate('User deleted successfully'), 'success') - return redirect(url_for('admin_users')) - -@app.route('/admin/users/reset_password/', 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() - - flash( - translate('New password for {username}: {password}', - username=user.username, - password=new_password), - 'info' - ) - return redirect(url_for('admin_users')) - - - # Apprise Notifications import apprise @@ -1058,74 +777,265 @@ def send_notification(user, game): return send_apprise_notification(user, game) 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) + with app.app_context(): + now = datetime.utcnow() + expiry_threshold = now + timedelta(hours=48) + + # Moderner Select-Aufruf + 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) # Optional: cleaning up old tokens 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() + now = datetime.utcnow() + expired = RedeemToken.query.filter(RedeemToken.expires < now).all() + for token in expired: + db.session.delete(token) + db.session.commit() # Scheduler start -scheduler = BackgroundScheduler(timezone=str(local_tz)) - -def check_expiring_keys_job(): - with app.app_context(): - check_expiring_keys() - -def cleanup_expired_tokens_job(): - with app.app_context(): - cleanup_expired_tokens() - -# Jobs hinzufügen -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' -) - -# Scheduler starten +scheduler = BackgroundScheduler() +scheduler.add_job(func=check_expiring_keys, trigger="interval", hours=interval_hours) +scheduler.add_job(func=cleanup_expired_tokens, trigger="interval", hours=1) scheduler.start() -atexit.register(lambda: scheduler.shutdown(wait=False)) + +# Shutdown of the Schedulers when stopping the app +atexit.register(lambda: scheduler.shutdown()) if __name__ == '__main__': with app.app_context(): db.create_all() - app.run(debug=True, host='0.0.0.0', port=5000) + app.run(host='0.0.0.0', port=5000) PYTHON_END +# 5. Dockerfile +cat < Dockerfile +FROM python:3.10-slim + +SHELL ["/bin/bash", "-c"] + +RUN apt-get update && apt-get install -y --no-install-recommends wget \ + && mkdir -p /app/static \ + && wget -O /app/static/logo.webp "https://drop.nocadmin.net/logo.webp" \ + && wget -O /app/static/logo_small.webp "https://drop.nocadmin.net/logo_small.webp" \ + && wget -O /app/static/forgejo.webp "https://drop.nocadmin.net/forgejo.webp" \ + && wget -O /app/static/gog_logo.webp "https://drop.nocadmin.net/gog_logo.webp" \ + && wget -O /app/static/logo_small_maskable.webp "https://drop.nocadmin.net/logo_small_maskable.webp" \ + && rm -rf /var/lib/apt/lists/* + +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 + + +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 -u \$UID -g \$GID -m appuser && \ + chown -R appuser:appuser /app + +USER appuser + +EXPOSE 5000 + +CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"] +DOCKER_END + +# 6. docker-compose.yml +cat < docker-compose.yml +services: + steam-manager: + build: . + ports: + - "5000:5000" + environment: + - REGISTRATION_ENABLED=${REGISTRATION_ENABLED:-True} + - TZ=${TZ} + volumes: + - ../data:/app/data + - ./translations:/app/translations:rw + - ../.env:/app/.env + user: "${UID}:${GID}" + restart: unless-stopped + +COMPOSE_END + +# 7. Directories and permissions +mkdir -p ../data ../translations +chmod -R a+rwX ../data ../translations +find ../data ../translations -type d -exec chmod 775 {} \; +find ../data ../translations -type f -exec chmod 664 {} \; + +cat <<'SCRIPT_END' > ../translate.sh +#!/bin/bash +set -e + +APP_DIR="steam-gift-manager" +TRANSLATION_DIR="$APP_DIR/translations" +LANGS=("de" "en") + +# Prüfe jq +if ! command -v jq &>/dev/null; then + echo "❌ jq is required. Install with: sudo apt-get install jq" + exit 1 +fi + +# 1. create json files if missing +for lang in "${LANGS[@]}"; do + file="$TRANSLATION_DIR/$lang.json" + if [ ! -f "$file" ]; then + echo "{}" > "$file" + echo "Created $file" + fi +done + +# 2. Extract the strings +STRINGS=$(grep -rhoP "_\(\s*['\"](.+?)['\"]\s*\)" \ + "$APP_DIR/templates" "$APP_DIR/app.py" | \ + sed -E "s/_\(\s*['\"](.+?)['\"]\s*\)/\1/" | sort | uniq) + +# 3. add new keys into the json files +for lang in "${LANGS[@]}"; do + file="$TRANSLATION_DIR/$lang.json" + tmp="$file.tmp" + cp "$file" "$tmp" + while IFS= read -r key; do + if ! jq -e --arg k "$key" 'has($k)' "$tmp" >/dev/null; then + jq --arg k "$key" '. + {($k): ""}' "$tmp" > "$tmp.new" && mv "$tmp.new" "$tmp" + fi + done <<< "$STRINGS" + mv "$tmp" "$file" + echo "Updated $file" +done +echo "✅ JSON translation files updated. Please enter your translations!" + + + +SCRIPT_END +chmod +x ../translate.sh + +cat <<'SCRIPT_END' > ../upgrade.sh +#!/bin/bash +set -e + +# Set the working directory to the project directory +cd "$(dirname "$0")/steam-gift-manager" + +# Setze FLASK_APP, falls nötig +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 < 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/logo_small.webp", + "sizes": "192x192", + "type": "image/webp", + "purpose": "any" + }, + { + "src": "/static/logo_small_maskable.webp", + "sizes": "192x192", + "type": "image/webp", + "purpose": "maskable" + }, + { + "src": "/static/logo.webp", + "sizes": "512x512", + "type": "image/webp", + "purpose": "any maskable" + } + ] +} +MANIFEST_END + + +# Service Worker +cat < static/serviceworker.js +const CACHE_NAME = 'game-key-manager-v2'; +const ASSETS = [ + '/', + '/static/style.css', + '/static/logo.webp', + '/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 + # 9. Templates mkdir -p templates static @@ -1145,8 +1055,9 @@ cat < templates/base.html - + + {# LCP-Optimierung: Preload für das erste Cover-Bild, falls vorhanden #} {% if games and games[0].steam_appid %} templates/base.html Logo Game Key Manager -
+
{% if current_user.is_authenticated %} - {% endif %}
@@ -1225,14 +1127,12 @@ cat < templates/base.html
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} -
- {% for category, message in messages %} - - {% endfor %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} {% endif %} {% endwith %} {% block content %}{% endblock %} @@ -1284,7 +1184,7 @@ cat < templates/base.html HTML_END # Index Template -cat <<'HTML_END' > templates/index.html +cat < templates/index.html {% extends "base.html" %} {% block content %}
@@ -1338,16 +1238,16 @@ cat <<'HTML_END' > templates/index.html {% if game.status == 'nicht eingelöst' %} {{ _('Not redeemed') }} - {% elif game.status == 'geschenkt' %} + {% elif game.status == 'verschenkt' %} {{ _('Gifted') }} {% elif game.status == 'eingelöst' %} {{ _('Redeemed') }} {% endif %} - {{ game.created_at|strftime('%d.%m.%Y') }} + {{ format_date(game.created_at) }} {% if game.redeem_date %} - {{ game.redeem_date|strftime('%d.%m.%Y') }} + {{ format_date(game.redeem_date) }} {% endif %} @@ -1356,9 +1256,8 @@ cat <<'HTML_END' > templates/index.html {% endif %} - {% if game.status == 'geschenkt' %} -
+
{% include "footer.html" %} diff --git a/steam-gift-manager/templates/change_password.html b/steam-gift-manager/templates/change_password.html index ca3f406..7d6943c 100644 --- a/steam-gift-manager/templates/change_password.html +++ b/steam-gift-manager/templates/change_password.html @@ -1,28 +1,22 @@ {% extends "base.html" %} {% block content %} -
-
-
-

{{ _('Change Password') }}

-
+
+

{{ _('Change Password') }}

+
- - + +
- - + +
- - + +
- {{ _('Cancel') }} - -
-
+
{% endblock %} - diff --git a/steam-gift-manager/templates/edit_game.html b/steam-gift-manager/templates/edit_game.html index 81db0bd..63e5384 100644 --- a/steam-gift-manager/templates/edit_game.html +++ b/steam-gift-manager/templates/edit_game.html @@ -1,67 +1,66 @@ {% extends "base.html" %} {% block content %}
-

{{ _('Edit Game') }}

-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- {% if redeem_url and active_redeem %} -
- - - - {{ _('Expires at') }}: {{ active_redeem.expires.strftime('%d.%m.%Y %H:%M') }} - +

{{ _('Edit Game') }}

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {% if redeem_url and active_redeem %} +
+ + + + {{ _('Expires at') }}: {{ active_redeem.expires.strftime('%d.%m.%Y %H:%M') }} + +
+ {% endif %} +
+
+ + {{ _('Cancel') }} +
- {% endif %} -
-
- - {{ _('Cancel') }} -
-
- +
{% endblock %} diff --git a/steam-gift-manager/templates/footer.html b/steam-gift-manager/templates/footer.html index fdf3d9f..4f0d5fa 100644 --- a/steam-gift-manager/templates/footer.html +++ b/steam-gift-manager/templates/footer.html @@ -5,7 +5,7 @@
diff --git a/steam-gift-manager/templates/import.html b/steam-gift-manager/templates/import.html index 79dc283..9abcc22 100644 --- a/steam-gift-manager/templates/import.html +++ b/steam-gift-manager/templates/import.html @@ -5,11 +5,11 @@
- +
- - {{ _('Cancel') }} + + {{ _('Abbrechen') }}
{% endblock %} diff --git a/steam-gift-manager/templates/index.html b/steam-gift-manager/templates/index.html index f9398a1..ff6b87b 100644 --- a/steam-gift-manager/templates/index.html +++ b/steam-gift-manager/templates/index.html @@ -31,19 +31,7 @@ {% if game.steam_appid %} Steam Header - {% elif game.url and 'gog.com' in game.url %} - GOG Logo + alt="Steam Header" style="height:64px;max-width:120px;object-fit:cover;"> {% endif %} {{ game.name }} diff --git a/steam-gift-manager/templates/login.html b/steam-gift-manager/templates/login.html index 0c0ccb7..0003326 100644 --- a/steam-gift-manager/templates/login.html +++ b/steam-gift-manager/templates/login.html @@ -1,43 +1,29 @@ {% extends "base.html" %} {% block content %} -
-
-

{{ _('Login') }}

-
- -
- - -
-
- - -
- {% if error %} - - {% endif %} - -
-
- {{ _('No account? Register here!') }} +
+
+
+
+ Logo +

{{ _('Login') }}

+
+ +
+ + +
+
+ + +
+ +
+ +
+
-
{% endblock %} - diff --git a/steam-gift-manager/templates/register.html b/steam-gift-manager/templates/register.html index b9b7ee0..40d6d62 100644 --- a/steam-gift-manager/templates/register.html +++ b/steam-gift-manager/templates/register.html @@ -1,51 +1,24 @@ {% extends "base.html" %} {% block content %} -
-
-

{{ _('Register') }}

-
- -
- - -
-
- - -
-
- - -
- {% if error %} - - {% endif %} - -
-
- {{ _('Already have an account? Login!') }} +
+
+
+
+

{{ _('Register') }}

+
+ +
+ + +
+
+ + +
+ +
+
+
-
{% endblock %} - diff --git a/steam-gift-manager/translations/de.json b/steam-gift-manager/translations/de.json deleted file mode 100644 index 0f3b89d..0000000 --- a/steam-gift-manager/translations/de.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "": "", - "Actions": "Aktionen", - "Active Redeem Link": "Aktiver Einlöse-Link", - "Add New Game": "Neues Spiel hinzufügen", - "Already have an account? Login!": "", - "Cancel": "Abbrechen", - "Change Password": "Passwort ändern", - "Change password form": "", - "Changes saved!": "Änderungen gespeichert!", - "Confirm New Password": "Neues Passwort bestätigen", - "Confirm Password": "", - "Cover": "Cover", - "Created": "Erstellt", - "Current Password": "Aktuelles Passwort", - "Current passwort is wrong": "Aktuelles Passwort ist falsch", - "Dark Mode": "Dunkler Modus", - "Edit Game": "Spiel bearbeiten", - "Error generating link": "Fehler beim Generieren des Links", - "Error: ": "Fehler: ", - "Expires at": "Ablaufdatum", - "Export CSV": "CSV exportieren", - "Game Key": "Spiele-Key", - "Game Key Manager": "Game-Key-Verwaltung", - "Game List (without Keys)": "Spieleliste (ohne Keys)", - "Game added successfully!": "Spiel erfolgreich hinzugefügt!", - "Generate redeem link": "Einlöse-Link generieren", - "Gifted": "Verschenkt", - "Import": "Importieren", - "Import CSV": "CSV importieren", - "Import Games": "Spiele importieren", - "Import error: %(error)s', error=str(e)), 'danger": "", - "Invalid credentials": "Ungültige Anmeldedaten", - "Key": "Key", - "Login": "Anmelden", - "Login form": "", - "Logout": "Abmelden", - "My Games": "Meine Spiele", - "Name": "Name", - "New Password": "Neues Passwort", - "New Passwords are not matching": "Neue Passwörter stimmen nicht überein", - "No account? Register here!": "", - "No games yet": "Der Kornspeicher ist leer, Sire!", - "No new registrations. They are deactivated!": "Keine neuen Registrierungen. Sie sind deaktiviert!", - "Not redeemed": "Nicht eingelöst", - "Notes": "Notizen", - "Password": "Passwort", - "Password changed successfully": "Passwort erfolgreich geändert", - "Please upload a valid CSV file.": "Bitte eine gültige CSV-Datei hochladen.", - "Really delete?": "Wirklich löschen?", - "Recipient": "Empfänger", - "Redeem by": "Einzulösen vor", - "Redeem link copied to clipboard!": "Einlöse-Link in die Zwischenablage kopiert!", - "Redeem now on": "Jetzt einlösen bei", - "Redeemed": "Eingelöst", - "Register": "Registrieren", - "Registration form": "", - "Save": "Speichern", - "Search": "Suche", - "Search games": "", - "Select CSV file": "CSV-Datei auswählen", - "Shop": "Shop", - "Shop URL": "Shop-URL", - "Status": "Status", - "Steam AppID (optional)": "Steam-AppID (optional)", - "Steam Key already exists!": "Steam-Key existiert bereits!", - "This page will expire in": "Diese Seite läuft ab in", - "Username": "Benutzername", - "Username already exists": "Benutzername existiert bereits", - "Your Key:": "Dein Key:" -} diff --git a/steam-gift-manager/translations/en.json b/steam-gift-manager/translations/en.json deleted file mode 100644 index 243f316..0000000 --- a/steam-gift-manager/translations/en.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "": "", - "Actions": "", - "Active Redeem Link": "", - "Add New Game": "", - "Already have an account? Login!": "", - "Cancel": "", - "Change Password": "", - "Change password form": "", - "Changes saved!": "", - "Confirm New Password": "", - "Confirm Password": "", - "Cover": "", - "Created": "", - "Current Password": "", - "Current passwort is wrong": "", - "Dark Mode": "", - "Edit Game": "", - "Error: ": "", - "Error generating link": "", - "Expires at": "", - "Export CSV": "", - "Game added successfully!": "", - "Game Key": "", - "Game Key Manager": "", - "Game List (without Keys)": "", - "Generate redeem link": "", - "Gifted": "", - "Import": "", - "Import CSV": "", - "Import error: %(error)s', error=str(e)), 'danger": "", - "Import Games": "", - "Invalid credentials": "", - "Key": "", - "Login": "", - "Login form": "", - "Logout": "", - "My Games": "", - "Name": "", - "%(new)d new games imported, %(dup)d skipped duplicates', new=new_games, dup=duplicates), 'success": "", - "New Password": "", - "New Passwords are not matching": "", - "No account? Register here!": "", - "No games yet": "", - "No new registrations. They are deactivated!": "", - "Notes": "", - "Not redeemed": "", - "Password": "", - "Password changed successfully": "", - "Please upload a valid CSV file.": "", - "Really delete?": "", - "Recipient": "", - "Redeem by": "", - "Redeemed": "", - "Redeem link copied to clipboard!": "", - "Redeem now on": "", - "Register": "", - "Registration form": "", - "Save": "", - "Search": "", - "Search games": "", - "Select CSV file": "", - "Shop": "", - "Shop URL": "", - "Status": "", - "Steam AppID (optional)": "", - "Steam Key already exists!": "", - "This page will expire in": "", - "Username": "", - "Username already exists": "", - "Your Key:": "" -} diff --git a/translate.sh b/translate.sh index 65b52bb..75e21e4 100755 --- a/translate.sh +++ b/translate.sh @@ -1,41 +1,28 @@ #!/bin/bash set -e -APP_DIR="steam-gift-manager" -TRANSLATION_DIR="$APP_DIR/translations" -LANGS=("de" "en") +cd "$(dirname "$0")/steam-gift-manager" -# Prüfe jq -if ! command -v jq &>/dev/null; then - echo "❌ jq is required. Install with: sudo apt-get install jq" - exit 1 -fi +declare -A locales=( + ["de"]="de" + ["en"]="en" +) -# 1. Lege JSON-Dateien an, falls sie fehlen -for lang in "${LANGS[@]}"; do - file="$TRANSLATION_DIR/$lang.json" - if [ ! -f "$file" ]; then - echo "{}" > "$file" - echo "Created $file" +# create POT-file +docker-compose exec steam-manager pybabel extract -F babel.cfg -o translations/messages.pot . + +# Check for each language and initialize if necessary +for lang in "${!locales[@]}"; do + if [ ! -f "translations/${locales[$lang]}/LC_MESSAGES/messages.po" ]; then + docker-compose exec steam-manager pybabel init \ + -i translations/messages.pot \ + -d translations \ + -l "${locales[$lang]}" fi done -# 2. Extrahiere alle zu übersetzenden Strings -STRINGS=$(grep -rhoP "_\(\s*['\"](.+?)['\"]\s*\)" \ - "$APP_DIR/templates" "$APP_DIR/app.py" | \ - sed -E "s/_\(\s*['\"](.+?)['\"]\s*\)/\1/" | sort | uniq) +# Update and compile translations +docker-compose exec steam-manager pybabel update -i translations/messages.pot -d translations +docker-compose exec steam-manager pybabel compile -d translations -# 3. Ergänze neue Keys in die JSON-Dateien -for lang in "${LANGS[@]}"; do - file="$TRANSLATION_DIR/$lang.json" - tmp="$file.tmp" - cp "$file" "$tmp" - while IFS= read -r key; do - if ! jq -e --arg k "$key" 'has($k)' "$tmp" >/dev/null; then - jq --arg k "$key" '. + {($k): ""}' "$tmp" > "$tmp.new" && mv "$tmp.new" "$tmp" - fi - done <<< "$STRINGS" - mv "$tmp" "$file" - echo "Updated $file" -done -echo "✅ JSON translation files updated. Please enter your translations!" +echo "✅ Translations updated!" diff --git a/translations/de/LC_MESSAGES/messages.mo b/translations/de/LC_MESSAGES/messages.mo new file mode 100644 index 0000000..7eaa6c9 Binary files /dev/null and b/translations/de/LC_MESSAGES/messages.mo differ diff --git a/translations/de/LC_MESSAGES/messages.po b/translations/de/LC_MESSAGES/messages.po new file mode 100644 index 0000000..aa6b016 --- /dev/null +++ b/translations/de/LC_MESSAGES/messages.po @@ -0,0 +1,274 @@ +# German translations for PROJECT. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-04-29 15:53+0000\n" +"PO-Revision-Date: 2025-04-29 15:42+0000\n" +"Last-Translator: FULL NAME \n" +"Language: de\n" +"Language-Team: de \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: app.py:194 +msgid "Invalid credentials" +msgstr "Ungültige Anmeldedaten" + +#: app.py:200 +msgid "No new registrations. They are deactivated!" +msgstr "Keine neuen Registrierungen. Sie sind deaktiviert!" + +#: app.py:208 +msgid "Username already exists" +msgstr "Benutzername existiert bereits" + +#: app.py:234 +msgid "Current passwort is wrong" +msgstr "Aktuelles Passwort ist falsch" + +#: app.py:238 +msgid "New Passwords are not matching" +msgstr "Neue Passwörter stimmen nicht überein" + +#: app.py:243 +msgid "Password changed successfully" +msgstr "Passwort erfolgreich geändert" + +#: app.py:273 +msgid "Game added successfully!" +msgstr "Spiel erfolgreich hinzugefügt!" + +#: app.py:278 +msgid "Steam Key already exists!" +msgstr "Steam-Key existiert bereits!" + +#: app.py:281 app.py:325 +msgid "Error: " +msgstr "Fehler: " + +#: app.py:320 +msgid "Changes saved!" +msgstr "Änderungen gespeichert!" + +#: app.py:408 +msgid "Game List (without Keys)" +msgstr "Spieleliste (ohne Keys)" + +#: app.py:501 +#, python-format +msgid "%(new)d new games imported, %(dup)d skipped duplicates" +msgstr "%(new)d neue Spiele importiert, %(dup)d Duplikate übersprungen" + +#: app.py:505 +#, python-format +msgid "Import error: %(error)s" +msgstr "Importfehler: %(error)s" + +#: app.py:509 +msgid "Please upload a valid CSV file." +msgstr "Bitte eine gültige CSV-Datei hochladen." + +#: templates/add_game.html:4 templates/index.html:9 +msgid "Add New Game" +msgstr "Neues Spiel hinzufügen" + +#: templates/add_game.html:9 templates/edit_game.html:9 templates/index.html:19 +msgid "Name" +msgstr "Name" + +#: templates/add_game.html:13 templates/edit_game.html:13 +msgid "Game Key" +msgstr "Spiele-Key" + +#: templates/add_game.html:17 templates/edit_game.html:21 templates/index.html:21 +msgid "Status" +msgstr "Status" + +#: templates/add_game.html:19 templates/edit_game.html:23 templates/index.html:41 +msgid "Not redeemed" +msgstr "Nicht eingelöst" + +#: templates/add_game.html:20 templates/edit_game.html:24 templates/index.html:43 +msgid "Gifted" +msgstr "Verschenkt" + +#: templates/add_game.html:21 templates/edit_game.html:25 templates/index.html:45 +msgid "Redeemed" +msgstr "Eingelöst" + +#: templates/add_game.html:25 templates/edit_game.html:29 templates/index.html:23 +msgid "Redeem by" +msgstr "Einzulösen bis" + +#: templates/add_game.html:29 templates/edit_game.html:33 +msgid "Recipient" +msgstr "Empfänger" + +#: templates/add_game.html:33 templates/edit_game.html:37 +msgid "Shop URL" +msgstr "Shop-URL" + +#: templates/add_game.html:37 templates/edit_game.html:41 +msgid "Notes" +msgstr "Notizen" + +#: templates/add_game.html:41 templates/edit_game.html:60 +msgid "Save" +msgstr "Speichern" + +#: templates/add_game.html:42 templates/edit_game.html:61 templates/import.html:12 +msgid "Cancel" +msgstr "Abbrechen" + +#: templates/base.html:7 +msgid "Game Key Manager" +msgstr "Game-Key-Verwaltung" + +#: templates/base.html:23 +msgid "Search" +msgstr "Suche" + +#: templates/base.html:31 +msgid "Dark Mode" +msgstr "Dunkler Modus" + +#: templates/base.html:46 templates/login.html:16 templates/register.html:15 +msgid "Password" +msgstr "Passwort" + +#: templates/base.html:49 +msgid "Logout" +msgstr "Abmelden" + +#: templates/change_password.html:4 templates/change_password.html:19 +msgid "Change Password" +msgstr "Passwort ändern" + +#: templates/change_password.html:8 +msgid "Current Password" +msgstr "Aktuelles Passwort" + +#: templates/change_password.html:12 +msgid "New Password" +msgstr "Neues Passwort" + +#: templates/change_password.html:16 +msgid "Confirm New Password" +msgstr "Neues Passwort bestätigen" + +#: templates/edit_game.html:4 +msgid "Edit Game" +msgstr "Spiel bearbeiten" + +#: templates/edit_game.html:17 +msgid "Steam AppID (optional)" +msgstr "Steam-AppID (optional)" + +#: templates/edit_game.html:47 +msgid "Active Redeem Link" +msgstr "Aktiver Einlöse-Link" + +#: templates/edit_game.html:54 +msgid "Expires at" +msgstr "Ablaufdatum" + +#: templates/import.html:4 +msgid "Import Games" +msgstr "Spiele importieren" + +#: templates/import.html:8 +msgid "Select CSV file" +msgstr "CSV-Datei auswählen" + +#: templates/import.html:11 +msgid "Import" +msgstr "Importieren" + +#: templates/index.html:4 +msgid "My Games" +msgstr "Meine Spiele" + +#: templates/index.html:6 +msgid "Export CSV" +msgstr "CSV exportieren" + +#: templates/index.html:8 +msgid "Import CSV" +msgstr "CSV importieren" + +#: templates/index.html:18 +msgid "Cover" +msgstr "Cover" + +#: templates/index.html:20 +msgid "Key" +msgstr "Key" + +#: templates/index.html:22 +msgid "Created" +msgstr "Erstellt" + +#: templates/index.html:24 templates/index.html:56 +msgid "Shop" +msgstr "Shop" + +#: templates/index.html:25 +msgid "Actions" +msgstr "Aktionen" + +#: templates/index.html:63 +msgid "Generate redeem link" +msgstr "Einlöse-Link generieren" + +#: templates/index.html:70 +msgid "Really delete?" +msgstr "Wirklich löschen?" + +#: templates/index.html:96 +msgid "Redeem link copied to clipboard!" +msgstr "Einlöse-Link in die Zwischenablage kopiert!" + +#: templates/index.html:100 +msgid "Error generating link" +msgstr "Fehler beim Generieren des Links" + +#: templates/index.html:106 +msgid "No games yet" +msgstr "Der Kornspeicher ist leer, Sire!" + +#: templates/login.html:8 templates/login.html:19 +msgid "Login" +msgstr "Anmelden" + +#: templates/login.html:12 templates/register.html:11 +msgid "Username" +msgstr "Benutzername" + +#: templates/login.html:22 +msgid "No account yet? Register" +msgstr "Noch kein Konto? Jetzt registrieren" + +#: templates/redeem.html:16 +msgid "Your Key:" +msgstr "Dein Key:" + +#: templates/redeem.html:22 +msgid "Redeem now on" +msgstr "Jetzt einlösen bei" + +#: templates/redeem.html:26 +msgid "This page will expire in" +msgstr "Diese Seite läuft ab in" + +#: templates/register.html:7 templates/register.html:18 +msgid "Register" +msgstr "Registrieren" + diff --git a/translations/en/LC_MESSAGES/messages.mo b/translations/en/LC_MESSAGES/messages.mo new file mode 100644 index 0000000..2cb4216 Binary files /dev/null and b/translations/en/LC_MESSAGES/messages.mo differ diff --git a/translations/en/LC_MESSAGES/messages.po b/translations/en/LC_MESSAGES/messages.po new file mode 100644 index 0000000..e7a3a4e --- /dev/null +++ b/translations/en/LC_MESSAGES/messages.po @@ -0,0 +1,280 @@ +# English translations for PROJECT. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-04-29 15:53+0000\n" +"PO-Revision-Date: 2025-04-29 15:42+0000\n" +"Last-Translator: FULL NAME \n" +"Language: en\n" +"Language-Team: en \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: app.py:194 +msgid "Invalid credentials" +msgstr "" + +#: app.py:200 +msgid "No new registrations. They are deactivated!" +msgstr "" + +#: app.py:208 +msgid "Username already exists" +msgstr "" + +#: app.py:234 +msgid "Current passwort is wrong" +msgstr "" + +#: app.py:238 +msgid "New Passwords are not matching" +msgstr "" + +#: app.py:243 +msgid "Password changed successfully" +msgstr "" + +#: app.py:273 +msgid "Game added successfully!" +msgstr "" + +#: app.py:278 +msgid "Steam Key already exists!" +msgstr "" + +#: app.py:281 app.py:325 +msgid "Error: " +msgstr "" + +#: app.py:320 +msgid "Changes saved!" +msgstr "" + +#: app.py:408 +msgid "Game List (without Keys)" +msgstr "" + +#: app.py:501 +#, python-format +msgid "%(new)d new games imported, %(dup)d skipped duplicates" +msgstr "" + +#: app.py:505 +#, python-format +msgid "Import error: %(error)s" +msgstr "" + +#: app.py:509 +msgid "Please upload a valid CSV file." +msgstr "" + +#: templates/add_game.html:4 templates/index.html:9 +msgid "Add New Game" +msgstr "" + +#: templates/add_game.html:9 templates/edit_game.html:9 templates/index.html:19 +msgid "Name" +msgstr "" + +#: templates/add_game.html:13 templates/edit_game.html:13 +msgid "Game Key" +msgstr "" + +#: templates/add_game.html:17 templates/edit_game.html:21 +#: templates/index.html:21 +msgid "Status" +msgstr "" + +#: templates/add_game.html:19 templates/edit_game.html:23 +#: templates/index.html:41 +msgid "Not redeemed" +msgstr "" + +#: templates/add_game.html:20 templates/edit_game.html:24 +#: templates/index.html:43 +msgid "Gifted" +msgstr "" + +#: templates/add_game.html:21 templates/edit_game.html:25 +#: templates/index.html:45 +msgid "Redeemed" +msgstr "" + +#: templates/add_game.html:25 templates/edit_game.html:29 +#: templates/index.html:23 +msgid "Redeem by" +msgstr "" + +#: templates/add_game.html:29 templates/edit_game.html:33 +msgid "Recipient" +msgstr "" + +#: templates/add_game.html:33 templates/edit_game.html:37 +msgid "Shop URL" +msgstr "" + +#: templates/add_game.html:37 templates/edit_game.html:41 +msgid "Notes" +msgstr "" + +#: templates/add_game.html:41 templates/edit_game.html:60 +msgid "Save" +msgstr "" + +#: templates/add_game.html:42 templates/edit_game.html:61 +#: templates/import.html:12 +msgid "Cancel" +msgstr "" + +#: templates/base.html:7 +msgid "Game Key Manager" +msgstr "" + +#: templates/base.html:23 +msgid "Search" +msgstr "" + +#: templates/base.html:31 +msgid "Dark Mode" +msgstr "" + +#: templates/base.html:46 templates/login.html:16 templates/register.html:15 +msgid "Password" +msgstr "" + +#: templates/base.html:49 +msgid "Logout" +msgstr "" + +#: templates/change_password.html:4 templates/change_password.html:19 +msgid "Change Password" +msgstr "" + +#: templates/change_password.html:8 +msgid "Current Password" +msgstr "" + +#: templates/change_password.html:12 +msgid "New Password" +msgstr "" + +#: templates/change_password.html:16 +msgid "Confirm New Password" +msgstr "" + +#: templates/edit_game.html:4 +msgid "Edit Game" +msgstr "" + +#: templates/edit_game.html:17 +msgid "Steam AppID (optional)" +msgstr "" + +#: templates/edit_game.html:47 +msgid "Active Redeem Link" +msgstr "" + +#: templates/edit_game.html:54 +msgid "Expires at" +msgstr "" + +#: templates/import.html:4 +msgid "Import Games" +msgstr "" + +#: templates/import.html:8 +msgid "Select CSV file" +msgstr "" + +#: templates/import.html:11 +msgid "Import" +msgstr "" + +#: templates/index.html:4 +msgid "My Games" +msgstr "" + +#: templates/index.html:6 +msgid "Export CSV" +msgstr "" + +#: templates/index.html:8 +msgid "Import CSV" +msgstr "" + +#: templates/index.html:18 +msgid "Cover" +msgstr "" + +#: templates/index.html:20 +msgid "Key" +msgstr "" + +#: templates/index.html:22 +msgid "Created" +msgstr "" + +#: templates/index.html:24 templates/index.html:56 +msgid "Shop" +msgstr "" + +#: templates/index.html:25 +msgid "Actions" +msgstr "" + +#: templates/index.html:63 +msgid "Generate redeem link" +msgstr "" + +#: templates/index.html:70 +msgid "Really delete?" +msgstr "" + +#: templates/index.html:96 +msgid "Redeem link copied to clipboard!" +msgstr "" + +#: templates/index.html:100 +msgid "Error generating link" +msgstr "" + +#: templates/index.html:106 +msgid "No games yet" +msgstr "" + +#: templates/login.html:8 templates/login.html:19 +msgid "Login" +msgstr "" + +#: templates/login.html:12 templates/register.html:11 +msgid "Username" +msgstr "" + +#: templates/login.html:22 +msgid "No account yet? Register" +msgstr "" + +#: templates/redeem.html:16 +msgid "Your Key:" +msgstr "" + +#: templates/redeem.html:22 +msgid "Redeem now on" +msgstr "" + +#: templates/redeem.html:26 +msgid "This page will expire in" +msgstr "" + +#: templates/register.html:7 templates/register.html:18 +msgid "Register" +msgstr "" + diff --git a/translations/messages.pot b/translations/messages.pot new file mode 100644 index 0000000..6306a35 --- /dev/null +++ b/translations/messages.pot @@ -0,0 +1,279 @@ +# Translations template for PROJECT. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-04-29 15:53+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: app.py:194 +msgid "Invalid credentials" +msgstr "" + +#: app.py:200 +msgid "No new registrations. They are deactivated!" +msgstr "" + +#: app.py:208 +msgid "Username already exists" +msgstr "" + +#: app.py:234 +msgid "Current passwort is wrong" +msgstr "" + +#: app.py:238 +msgid "New Passwords are not matching" +msgstr "" + +#: app.py:243 +msgid "Password changed successfully" +msgstr "" + +#: app.py:273 +msgid "Game added successfully!" +msgstr "" + +#: app.py:278 +msgid "Steam Key already exists!" +msgstr "" + +#: app.py:281 app.py:325 +msgid "Error: " +msgstr "" + +#: app.py:320 +msgid "Changes saved!" +msgstr "" + +#: app.py:408 +msgid "Game List (without Keys)" +msgstr "" + +#: app.py:501 +#, python-format +msgid "%(new)d new games imported, %(dup)d skipped duplicates" +msgstr "" + +#: app.py:505 +#, python-format +msgid "Import error: %(error)s" +msgstr "" + +#: app.py:509 +msgid "Please upload a valid CSV file." +msgstr "" + +#: templates/add_game.html:4 templates/index.html:9 +msgid "Add New Game" +msgstr "" + +#: templates/add_game.html:9 templates/edit_game.html:9 templates/index.html:19 +msgid "Name" +msgstr "" + +#: templates/add_game.html:13 templates/edit_game.html:13 +msgid "Game Key" +msgstr "" + +#: templates/add_game.html:17 templates/edit_game.html:21 +#: templates/index.html:21 +msgid "Status" +msgstr "" + +#: templates/add_game.html:19 templates/edit_game.html:23 +#: templates/index.html:41 +msgid "Not redeemed" +msgstr "" + +#: templates/add_game.html:20 templates/edit_game.html:24 +#: templates/index.html:43 +msgid "Gifted" +msgstr "" + +#: templates/add_game.html:21 templates/edit_game.html:25 +#: templates/index.html:45 +msgid "Redeemed" +msgstr "" + +#: templates/add_game.html:25 templates/edit_game.html:29 +#: templates/index.html:23 +msgid "Redeem by" +msgstr "" + +#: templates/add_game.html:29 templates/edit_game.html:33 +msgid "Recipient" +msgstr "" + +#: templates/add_game.html:33 templates/edit_game.html:37 +msgid "Shop URL" +msgstr "" + +#: templates/add_game.html:37 templates/edit_game.html:41 +msgid "Notes" +msgstr "" + +#: templates/add_game.html:41 templates/edit_game.html:60 +msgid "Save" +msgstr "" + +#: templates/add_game.html:42 templates/edit_game.html:61 +#: templates/import.html:12 +msgid "Cancel" +msgstr "" + +#: templates/base.html:7 +msgid "Game Key Manager" +msgstr "" + +#: templates/base.html:23 +msgid "Search" +msgstr "" + +#: templates/base.html:31 +msgid "Dark Mode" +msgstr "" + +#: templates/base.html:46 templates/login.html:16 templates/register.html:15 +msgid "Password" +msgstr "" + +#: templates/base.html:49 +msgid "Logout" +msgstr "" + +#: templates/change_password.html:4 templates/change_password.html:19 +msgid "Change Password" +msgstr "" + +#: templates/change_password.html:8 +msgid "Current Password" +msgstr "" + +#: templates/change_password.html:12 +msgid "New Password" +msgstr "" + +#: templates/change_password.html:16 +msgid "Confirm New Password" +msgstr "" + +#: templates/edit_game.html:4 +msgid "Edit Game" +msgstr "" + +#: templates/edit_game.html:17 +msgid "Steam AppID (optional)" +msgstr "" + +#: templates/edit_game.html:47 +msgid "Active Redeem Link" +msgstr "" + +#: templates/edit_game.html:54 +msgid "Expires at" +msgstr "" + +#: templates/import.html:4 +msgid "Import Games" +msgstr "" + +#: templates/import.html:8 +msgid "Select CSV file" +msgstr "" + +#: templates/import.html:11 +msgid "Import" +msgstr "" + +#: templates/index.html:4 +msgid "My Games" +msgstr "" + +#: templates/index.html:6 +msgid "Export CSV" +msgstr "" + +#: templates/index.html:8 +msgid "Import CSV" +msgstr "" + +#: templates/index.html:18 +msgid "Cover" +msgstr "" + +#: templates/index.html:20 +msgid "Key" +msgstr "" + +#: templates/index.html:22 +msgid "Created" +msgstr "" + +#: templates/index.html:24 templates/index.html:56 +msgid "Shop" +msgstr "" + +#: templates/index.html:25 +msgid "Actions" +msgstr "" + +#: templates/index.html:63 +msgid "Generate redeem link" +msgstr "" + +#: templates/index.html:70 +msgid "Really delete?" +msgstr "" + +#: templates/index.html:96 +msgid "Redeem link copied to clipboard!" +msgstr "" + +#: templates/index.html:100 +msgid "Error generating link" +msgstr "" + +#: templates/index.html:106 +msgid "No games yet" +msgstr "" + +#: templates/login.html:8 templates/login.html:19 +msgid "Login" +msgstr "" + +#: templates/login.html:12 templates/register.html:11 +msgid "Username" +msgstr "" + +#: templates/login.html:22 +msgid "No account yet? Register" +msgstr "" + +#: templates/redeem.html:16 +msgid "Your Key:" +msgstr "" + +#: templates/redeem.html:22 +msgid "Redeem now on" +msgstr "" + +#: templates/redeem.html:26 +msgid "This page will expire in" +msgstr "" + +#: templates/register.html:7 templates/register.html:18 +msgid "Register" +msgstr "" +