Files
launchpad/app.py

275 lines
11 KiB
Python

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
)
# ----------------------------------------------------------------------------
# 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("/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/<int:app_id>/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/<int:app_id>/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/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
);
"""
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.execute(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)