import os import sqlite3 from datetime import datetime from pathlib import Path from urllib.parse import urlparse from flask import ( Flask, render_template, request, redirect, url_for, flash, session, jsonify, abort ) from werkzeug.security import generate_password_hash, check_password_hash # ---------------------------------------------------------------------------- # Config # ---------------------------------------------------------------------------- BASE_DIR = Path(__file__).resolve().parent DB_PATH = BASE_DIR / "launchpad.db" UPLOADS_DIR = BASE_DIR / "static" / "uploads" ALLOWED_IMAGE_EXT = {"png", "jpg", "jpeg", "webp", "gif"} def create_app(): app = Flask(__name__, template_folder=str(BASE_DIR / "templates"), static_folder=str(BASE_DIR / "static")) # Minimal secrets; override in .env or environment app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-change-me") app.config["ADMIN_USER"] = os.getenv("ADMIN_USER", "admin") app.config["ADMIN_PASS"] = os.getenv("ADMIN_PASS", "admin123") app.config["PUBLIC_BASE_URL"] = os.getenv("PUBLIC_BASE_URL", "").rstrip("/") # Ensure DB exists init_db() # Ensure uploads dir exists UPLOADS_DIR.mkdir(parents=True, exist_ok=True) # --------------------------- # Helpers # --------------------------- def get_db(): return sqlite3.connect(DB_PATH) def login_required(fn): from functools import wraps @wraps(fn) def wrapper(*args, **kwargs): if not session.get("user"): return redirect(url_for("login", next=request.path)) return fn(*args, **kwargs) return wrapper def validate_url(u: str) -> bool: try: p = urlparse(u) return p.scheme in {"http", "https"} and bool(p.netloc) except Exception: return False def make_absolute(img_path_or_url: str) -> str: """Return an absolute https/http URL for images. If already absolute, return as is. If it's a /static/... path, prefix with PUBLIC_BASE_URL if set, otherwise request.url_root. """ if not img_path_or_url: return img_path_or_url low = img_path_or_url.lower() if low.startswith("http://") or low.startswith("https://"): return img_path_or_url # Use configured public base if provided; else request.url_root (requires request context) base = app.config.get("PUBLIC_BASE_URL") or request.url_root.rstrip('/') if not img_path_or_url.startswith('/'): img_path_or_url = '/' + img_path_or_url return f"{base}{img_path_or_url}" def allowed_image(filename: str) -> bool: return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXT def save_image(file_storage) -> str | None: """Save uploaded image to static/uploads and return its web path beginning with /static/...""" from werkzeug.utils import secure_filename if not file_storage or not file_storage.filename: return None if not allowed_image(file_storage.filename): return None name = secure_filename(file_storage.filename) # unique filename ts = datetime.utcnow().strftime("%Y%m%d%H%M%S%f") ext = name.rsplit(".", 1)[1].lower() base = name.rsplit(".", 1)[0] final_name = f"{base}_{ts}.{ext}" dest = UPLOADS_DIR / final_name file_storage.save(dest) return f"/static/uploads/{final_name}" # --------------------------- # Auth # --------------------------- @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": username = request.form.get("username", "").strip() password = request.form.get("password", "") if username == app.config["ADMIN_USER"] and password == app.config["ADMIN_PASS"]: session["user"] = username flash("Welcome!", "success") return redirect(request.args.get("next") or url_for("apps_list")) flash("Invalid credentials", "danger") return render_template("login.html") @app.route("/logout") def logout(): session.clear() flash("Logged out", "info") return redirect(url_for("login")) # --------------------------- # UI Pages # --------------------------- @app.route("/") @login_required def apps_list(): query = "SELECT id, name, image, url, enabled, created_at FROM apps ORDER BY priority DESC, name ASC" with get_db() as conn: conn.row_factory = sqlite3.Row rows = conn.execute(query).fetchall() return render_template("apps_list.html", apps=rows) @app.route("/admin/users/new", methods=["GET", "POST"]) @login_required def user_create(): """Admin page to create application users (username + password).""" if request.method == "POST": username = (request.form.get("username", "").strip()).lower() password = request.form.get("password", "") # Basic validations if not username: flash("Username is required", "danger") return render_template("create_user.html") if not password or len(password) < 6: flash("Password must be at least 6 characters", "danger") return render_template("create_user.html") pwd_hash = generate_password_hash(password) try: with get_db() as conn: conn.execute( "INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)", (username, pwd_hash, datetime.utcnow().isoformat(timespec='seconds')), ) conn.commit() flash("User created", "success") return redirect(url_for("apps_list")) except sqlite3.IntegrityError: flash("Username already exists", "danger") return render_template("create_user.html") # GET return render_template("create_user.html") @app.route("/apps/new", methods=["GET", "POST"]) @login_required def app_create(): if request.method == "POST": name = request.form.get("name", "").strip() image_file = request.files.get("image_file") # NEW image = request.form.get("image", "").strip() url = request.form.get("url", "").strip() enabled = 1 if request.form.get("enabled") == "on" else 0 priority = int(request.form.get("priority", 0) or 0) if not name: flash("Name is required", "danger") return render_template("app_form.html", form_action=url_for("app_create")) # Prefer uploaded file over URL saved_image_path = save_image(image_file) if image_file else None if saved_image_path: image = saved_image_path elif image and not validate_url(image): flash("Image must be a valid http(s) URL or upload a file", "danger") return render_template("app_form.html", form_action=url_for("app_create")) if not validate_url(url): flash("App URL must be a valid http(s) URL", "danger") return render_template("app_form.html", form_action=url_for("app_create")) # Ensure image stored as absolute URL image = make_absolute(image) if image else "" with get_db() as conn: conn.execute( "INSERT INTO apps (name, image, url, enabled, priority, created_at) VALUES (?,?,?,?,?,?)", (name, image, url, enabled, priority, datetime.utcnow().isoformat(timespec='seconds')), ) conn.commit() flash("App added", "success") return redirect(url_for("apps_list")) return render_template("app_form.html", form_action=url_for("app_create")) @app.route("/apps//edit", methods=["GET", "POST"]) @login_required def app_edit(app_id: int): with get_db() as conn: conn.row_factory = sqlite3.Row row = conn.execute("SELECT id, name, image, url, enabled, priority FROM apps WHERE id=?", (app_id,)).fetchone() if not row: abort(404) if request.method == "POST": name = request.form.get("name", "").strip() image_file = request.files.get("image_file") # NEW url = request.form.get("url", "").strip() enabled = 1 if request.form.get("enabled") == "on" else 0 priority = int(request.form.get("priority", 0) or 0) # Prefer uploaded file > new URL > keep old value new_image = row["image"] saved_image_path = save_image(image_file) if image_file else None if saved_image_path: new_image = saved_image_path else: form_image = request.form.get("image", "").strip() if form_image: if not validate_url(form_image): flash("Image must be a valid http(s) URL or upload a file", "danger") return render_template("app_form.html", form_action=url_for("app_edit", app_id=app_id), data=row) new_image = form_image if not name: flash("Name is required", "danger") return render_template("app_form.html", form_action=url_for("app_edit", app_id=app_id), data=row) if not validate_url(url): flash("App URL must be a valid http(s) URL", "danger") return render_template("app_form.html", form_action=url_for("app_edit", app_id=app_id), data=row) new_image = make_absolute(new_image) if new_image else "" with get_db() as conn: conn.execute( "UPDATE apps SET name=?, image=?, url=?, enabled=?, priority=? WHERE id=?", (name, new_image, url, enabled, priority, app_id), ) conn.commit() flash("App updated", "success") return redirect(url_for("apps_list")) return render_template("app_form.html", form_action=url_for("app_edit", app_id=app_id), data=row) @app.route("/apps//delete", methods=["POST"]) @login_required def app_delete(app_id: int): with get_db() as conn: conn.execute("DELETE FROM apps WHERE id=?", (app_id,)) conn.commit() flash("App deleted", "warning") return redirect(url_for("apps_list")) # --------------------------- # API Endpoint (public or protected — here public for simplicity) # --------------------------- @app.route("/api/login", methods=["POST"]) def api_login(): """Simple login endpoint for the Android app. Expects JSON: {"username": "...", "password": "..."} Returns 200 on success with optional token field, 401 on failure. """ try: payload = request.get_json(silent=True) or {} username = (payload.get("username") or "").strip().lower() password = payload.get("password") or "" except Exception: return jsonify({"error": "Invalid JSON"}), 400 if not username or not password: return jsonify({"error": "username and password are required"}), 400 with sqlite3.connect(DB_PATH) as conn: conn.row_factory = sqlite3.Row row = conn.execute( "SELECT id, username, password_hash FROM users WHERE username=?", (username,), ).fetchone() if not row or not check_password_hash(row["password_hash"], password): return jsonify({"error": "Invalid credentials"}), 401 # For now we do not issue a JWT; Android accepts null/absent token. # If you later add JWT, return it here as {"token": "..."} return jsonify({"token": None}), 200 @app.route("/api/apps", methods=["GET"]) def api_apps(): with get_db() as conn: conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT id, name, image, url FROM apps WHERE enabled=1 ORDER BY priority DESC, name ASC" ).fetchall() data = [] for r in rows: d = dict(r) img = d.get("image") or "" d["image"] = make_absolute(img) if img else "" data.append(d) return jsonify({ "version": 1, "updated_at": datetime.utcnow().isoformat(timespec='seconds') + "Z", "apps": data, }) return app # ---------------------------------------------------------------------------- # DB bootstrap # ---------------------------------------------------------------------------- SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS apps ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, image TEXT, url TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, priority INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL ); """ def init_db(): DB_PATH.parent.mkdir(parents=True, exist_ok=True) with sqlite3.connect(DB_PATH) as conn: conn.execute("PRAGMA journal_mode=WAL;") conn.executescript(SCHEMA_SQL) conn.commit() # ---------------------------------------------------------------------------- # Dev entrypoint # ---------------------------------------------------------------------------- if __name__ == "__main__": app = create_app() app.run(host="0.0.0.0", port=int(os.getenv("PORT", 5000)), debug=True)