diff --git a/plugins/knowledgebase/api/routes.py b/plugins/knowledgebase/api/routes.py index f782b6bb..54008a1b 100644 --- a/plugins/knowledgebase/api/routes.py +++ b/plugins/knowledgebase/api/routes.py @@ -1,7 +1,6 @@ """Knowledge Base API endpoints.""" from flask import Blueprint, request -from flask_jwt_extended import jwt_required from shopdb.api import ( intarg, @@ -180,21 +179,29 @@ def get_article(link_id: int): @knowledgebase_bp.route('//click', methods=['POST']) -@jwt_required(optional=True) +@require_permission('kb.view') def track_click(link_id: int): - """Increment click counter and return the URL to redirect to.""" - article = db.session.get(KnowledgeBase, link_id) + """Increment the click counter for an article the caller is allowed to read. - if not article or not article.isactive: + Gated on kb.view like every other read of this table: an anonymous caller + walking link ids must not be able to probe which articles exist, drive the + counters that order the list, or spend a DB transaction per request. + + Restricted to the same visible set as the list/detail reads so an article + about a retired application is not reachable here either. + + The linkurl is deliberately not echoed back: whoever is permitted to click + already received it in the list or detail response the click came from. + """ + article = _visible_articles().filter(KnowledgeBase.linkid == link_id).first() + + if not article: return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) article.increment_clicks() db.session.commit() - return success_response({ - 'linkurl': article.linkurl, - 'clicks': article.clicks - }) + return success_response({'clicks': article.clicks}) @knowledgebase_bp.route('', methods=['POST']) diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 9000bce8..cdb3677a 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -204,6 +204,27 @@ def create_app(config_name: str = None) -> Flask: # Initialize plugin manager with app.app_context(): + # On first run (no users exist), log the setup token so the operator can + # create the first admin. The token gates POST /api/setup/create-admin to + # prevent a network race during the install window (API starts serving + # before the admin exists in every documented deploy path). + from shopdb.core.models import User + try: + if User.query.count() == 0: + token = app.config.get('SETUP_TOKEN') or os.environ.get('SETUP_TOKEN') + if token: + app.logger.warning('=' * 60) + app.logger.warning('FIRST RUN: No users exist.') + app.logger.warning('Setup token: %s', token) + app.logger.warning('Create admin: POST /api/setup/create-admin') + app.logger.warning(' with header: X-Setup-Token: ') + app.logger.warning('OR use CLI: docker exec shopdb-api flask seed admin') + app.logger.warning('=' * 60) + else: + app.logger.warning('FIRST RUN: No users exist, no SETUP_TOKEN set.') + app.logger.warning('Use CLI: docker exec shopdb-api flask seed admin') + except Exception: + pass # DB not ready yet, skip plugin_manager.init_app(app, db) # Register core blueprints diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index 687ddc81..9556eca0 100644 --- a/shopdb/cli/__init__.py +++ b/shopdb/cli/__init__.py @@ -199,6 +199,32 @@ def seed_cli(): pass +@seed_cli.command('show-setup-token') +@with_appcontext +def show_setup_token(): + """Display the setup token for first-admin creation. + + The token gates POST /api/setup/create-admin so a network race cannot claim + the instance during the install window. Printed at first startup; retrieve + it here if the logs rotated or you lost the terminal. + """ + import os + from flask import current_app + from shopdb.core.models import User + + if User.query.count() > 0: + click.echo(click.style("Setup already complete (users exist).", fg='green')) + return + + token = current_app.config.get('SETUP_TOKEN') or os.environ.get('SETUP_TOKEN') + if token: + click.echo(click.style("Setup token: ", fg='cyan') + token) + click.echo("Use: curl -X POST .../api/setup/create-admin -H 'X-Setup-Token: ' ...") + else: + click.echo(click.style("No SETUP_TOKEN configured.", fg='yellow')) + click.echo("Use 'flask seed admin' from inside the container (no token needed).") + + @seed_cli.command('catalog') @click.option('--file', 'path', default=None, help='catalog JSON to load (default shopdb/data/catalog.json)') diff --git a/shopdb/core/api/setup.py b/shopdb/core/api/setup.py index ff748294..8c7d539e 100644 --- a/shopdb/core/api/setup.py +++ b/shopdb/core/api/setup.py @@ -5,6 +5,10 @@ server work: seeding common starter data and marking setup finished. Site, plugin, and map configuration reuse the existing settings / plugins APIs. """ +import hmac +import ipaddress +import os + from flask import Blueprint, request, current_app from flask_jwt_extended import jwt_required from werkzeug.security import generate_password_hash @@ -29,6 +33,45 @@ STARTER_VENDORS = [ ] +# Minimum length for the bootstrap administrator password. 12 characters is the +# shortest length that still resists offline cracking of a single leaked hash. +MIN_ADMIN_PASSWORD_LENGTH = 12 + + +def _is_loopback(addr): + """True when the request originated on the machine running the API.""" + if not addr: + return False + try: + return ipaddress.ip_address(addr.split('%')[0]).is_loopback + except ValueError: + return False + + +def _bootstrap_allowed(): + """Proof of operator control for first-admin creation. + + An empty users table is an ordering condition, not authorization: the API + serves requests before the first account exists on every fresh deploy, so a + remote caller could otherwise claim the instance during the install window. + Require either the one-time setup token (SETUP_TOKEN in config or the + environment, presented as the X-Setup-Token header) or a request coming from + loopback, i.e. the host the operator is installing on. + """ + token = str(current_app.config.get('SETUP_TOKEN') + or os.environ.get('SETUP_TOKEN') or '').strip() + if token: + supplied = (request.headers.get('X-Setup-Token') or '').strip() + return bool(supplied) and hmac.compare_digest(supplied, token) + return _is_loopback(request.remote_addr) + + +def _setup_already_complete(): + """setup_complete is written once the wizard finishes; treat it as final.""" + row = Setting.query.filter_by(key='setup_complete').first() + return bool(row) and str(row.value).strip().lower() in ('true', '1', 'yes') + + @setup_bp.route('/needs-admin', methods=['GET']) def needs_admin(): """True when no users exist yet - the app's very first run. Public so the @@ -38,11 +81,18 @@ def needs_admin(): @setup_bp.route('/create-admin', methods=['POST']) def create_admin(): - """Create the first admin. Only works while there are zero users, so it - can't be abused after setup. No auth (there is no one to auth as yet).""" - if User.query.count() > 0: + """Create the first admin. Only works while there are zero users, setup has + not been marked complete, and the caller proves operator control (setup + token or a loopback request).""" + if User.query.count() > 0 or _setup_already_complete(): return error_response(ErrorCodes.FORBIDDEN, 'An account already exists; sign in instead.', http_code=403) + if not _bootstrap_allowed(): + return error_response( + ErrorCodes.FORBIDDEN, + 'First-admin creation requires the setup token (X-Setup-Token) ' + 'or a request from the server itself.', + http_code=403) data = request.get_json() or {} username = (data.get('username') or '').strip() email = (data.get('email') or '').strip() @@ -50,6 +100,10 @@ def create_admin(): if not (username and email and password): return error_response(ErrorCodes.VALIDATION_ERROR, 'username, email and password are required') + if len(password) < MIN_ADMIN_PASSWORD_LENGTH: + return error_response( + ErrorCodes.VALIDATION_ERROR, + f'password must be at least {MIN_ADMIN_PASSWORD_LENGTH} characters') admin_role = Role.query.filter_by(rolename='admin').first() if not admin_role: @@ -61,6 +115,8 @@ def create_admin(): user.roles.append(admin_role) db.session.add(user) db.session.commit() + current_app.logger.warning('First administrator %r created from %s', + username, request.remote_addr) return success_response({'username': username}, message='Admin created', http_code=201)