diff --git a/app/__init__.py b/app/__init__.py index 0b90748..fe40b6d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -46,10 +46,12 @@ def create_app(config_class=Config): from .routes import main from .blueprints.slides import slides_bp from .blueprints.admin import admin_bp + from .blueprints.internal import internal_bp app.register_blueprint(main) app.register_blueprint(slides_bp) app.register_blueprint(admin_bp) + app.register_blueprint(internal_bp) @app.template_filter('md') def render_markdown(text): diff --git a/app/blueprints/internal/__init__.py b/app/blueprints/internal/__init__.py new file mode 100644 index 0000000..03cbecb --- /dev/null +++ b/app/blueprints/internal/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +internal_bp = Blueprint('internal', __name__, url_prefix='/internal') + +from . import routes #noqa diff --git a/app/blueprints/internal/routes.py b/app/blueprints/internal/routes.py new file mode 100644 index 0000000..8b7c646 --- /dev/null +++ b/app/blueprints/internal/routes.py @@ -0,0 +1,87 @@ +import hmac +from datetime import datetime +import pytz +from flask import request, jsonify, current_app +from app.models import db, Deck, Course, Password +from . import internal_bp + + +@internal_bp.route('/register-deck', methods=['POST']) +def register_deck(): + # Verify shared secret + token = request.headers.get('X-Internal-Token', '') + if not hmac.compare_digest(token, current_app.config['INTERNAL_TOKEN']): + return jsonify({'error': 'Unauthorized'}), 401 + + data = request.get_json() + if not data: + return jsonify({'error': 'Invalid JSON'}), 400 + + for field in ('course', 'deck', 'name'): + if not data.get(field): + return jsonify({'error': f'Missing required field: {field}'}), 400 + + # Look up course by folder name + course = db.session.execute( + db.select(Course).where(Course.folder == data['course']) + ).scalar_one_or_none() + if not course: + return jsonify({'error': f"Course '{data['course']}' not found"}), 404 + + # Upsert deck + # NOTE: Deck.__init__ takes course= (object), not course_id= + deck = db.session.execute( + db.select(Deck).where( + Deck.deck == data['deck'], + Deck.course_id == course.id + ) + ).scalar_one_or_none() + + if deck: + deck.name = data['name'] + deck.description = data['description'] + deck.export_file = data.get('export_file') or None + else: + deck = Deck( + name = data['name'], + deck = data['deck'], + course = course, # pass object, not course_id + description = data['description'], + index_file = data.get('index_file', 'index.html'), + export_file = data.get('export_file') or None, + ) + db.session.add(deck) + db.session.flush() # populate deck.id before linking password + + # Upsert password if provided + if data.get('password'): + expires_at = None + if data.get('password_expires'): + try: + # Parse ISO string and convert to UTC naive (matches UTCDateTime model) + expires_at = datetime.fromisoformat( + data['password_expires'].replace('Z', '+00:00') + ).astimezone(pytz.utc) + except ValueError: + current_app.logger.warning( + f"Invalid password_expires format: {data['password_expires']}" + ) + + existing_pw = db.session.execute( + db.select(Password).where(Password.value == data['password']) + ).scalar_one_or_none() + + if existing_pw: + existing_pw.expires_at = expires_at + if deck not in existing_pw.decks: + existing_pw.decks.append(deck) + else: + pw = Password(value=data['password'], expires_at=expires_at) + pw.decks.append(deck) + db.session.add(pw) + + db.session.commit() + current_app.logger.info( + f"Registered deck '{data['deck']}' for course '{data['course']}'" + ) + return jsonify({'status': 'ok', 'deck': data['deck']}), 200 diff --git a/app/config.py b/app/config.py index 0507eea..0d89897 100644 --- a/app/config.py +++ b/app/config.py @@ -8,6 +8,7 @@ class Config: SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") SQLALCHEMY_TRACK_MODIFICATIONS = False ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") + INTERNAL_TOKEN = os.environ.get("INTERNAL_TOKEN") COURSE_MAP = { "GRNVS25": "GRNVS Tutorium SS 25", "IT-Sec25": "IT-Sec Tutorium WS 25/26", diff --git a/app/models.py b/app/models.py index 4f2fc78..5a1ea6c 100644 --- a/app/models.py +++ b/app/models.py @@ -83,7 +83,7 @@ class Deck(db.Model): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(60), unique=True, nullable=False) deck: Mapped[str] = mapped_column(String(60), nullable=False) - description: Mapped[str] = mapped_column(String(60)) + description: Mapped[str] = mapped_column(String(200)) index_file: Mapped[str] = mapped_column(String(40), nullable=False) export_file: Mapped[Optional[str]] = mapped_column(String(40), nullable=True) diff --git a/docker-compose.yml b/docker-compose.yml index 0d4a7ab..a9ec711 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,3 +75,18 @@ services: depends_on: - prometheus - db + + act-runner: + image: gitea/act_runner:latest + restart: unless-stopped + environment: + - GITEA_INSTANCE_URL=https://gitea.cato447.de + - GITEA_RUNNER_REGISTRATION_TOKEN=${ACT_RUNNER_TOKEN} + - GITEA_RUNNER_NAME=tutor-runner + - GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:22 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./data/act_runner:/data + - ./data/slides:/slides + - ./data/exports:/exports + mem_limit: 2g