Initial commit for LaunchPad app
This commit is contained in:
275
app.py
Normal file
275
app.py
Normal file
@@ -0,0 +1,275 @@
|
||||
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)
|
||||
BIN
launchpad.db
Normal file
BIN
launchpad.db
Normal file
Binary file not shown.
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Flask==3.0.3
|
||||
python-dotenv==1.0.1
|
||||
27
static/styles.css
Normal file
27
static/styles.css
Normal file
@@ -0,0 +1,27 @@
|
||||
:root { --gap: 12px; --pad: 16px; --accent: #2b6cb0; --danger: #c53030; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; background:#f7fafc; color:#1a202c; }
|
||||
.topbar { display:flex; justify-content:space-between; align-items:center; padding: var(--pad); background:#fff; border-bottom:1px solid #e2e8f0; position:sticky; top:0; }
|
||||
.topbar .brand { font-weight:700; }
|
||||
.topbar nav a { margin-left: 10px; text-decoration:none; color:#2d3748; }
|
||||
.container { max-width: 980px; margin: 24px auto; padding: 0 var(--pad); }
|
||||
.flash { margin-bottom: var(--pad); }
|
||||
.flash-item { padding: 10px; border-radius:6px; margin-bottom:8px; background:#e6fffa; }
|
||||
.flash-item.success { background:#c6f6d5; }
|
||||
.flash-item.info { background:#bee3f8; }
|
||||
.flash-item.danger { background:#fed7d7; }
|
||||
.header-row { display:flex; justify-content:space-between; align-items:center; margin-bottom: var(--pad); }
|
||||
.btn { display:inline-block; padding:8px 12px; border-radius:6px; background:#edf2f7; color:#1a202c; text-decoration:none; border:1px solid #cbd5e0; }
|
||||
.btn.primary { background:#3182ce; color:white; border-color:#2b6cb0; }
|
||||
.btn.danger { background:#e53e3e; color:white; border-color:#c53030; }
|
||||
.btn.small { padding:6px 8px; font-size: 0.9rem; }
|
||||
.table { width:100%; border-collapse: collapse; background:#fff; border:1px solid #e2e8f0; }
|
||||
.table th, .table td { padding:10px; border-bottom:1px solid #edf2f7; vertical-align: middle; }
|
||||
.table thead th { background:#f1f5f9; text-align:left; }
|
||||
.icon { width: 32px; height: 32px; border-radius: 6px; object-fit: cover; }
|
||||
.form { display:grid; gap: var(--gap); max-width: 600px; }
|
||||
.form label { display:grid; gap:6px; }
|
||||
.form input[type=text], .form input[type=password], .form input[type=url], .form input[type=number] { padding:10px; border:1px solid #cbd5e0; border-radius:6px; background:#fff; }
|
||||
.form .checkbox { display:flex; align-items:center; gap:10px; }
|
||||
.form-actions { display:flex; gap:10px; margin-top: 8px; }
|
||||
.actions { white-space: nowrap; }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
42
templates/app_form.html
Normal file
42
templates/app_form.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'Edit App' if data else 'Add App' }} – LaunchPad Admin{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ 'Edit App' if data else 'Add App' }}</h1>
|
||||
<form method="post" class="form" enctype="multipart/form-data">
|
||||
<label>Name
|
||||
<input type="text" name="name" value="{{ (data.name if data else '') | default('') }}" required />
|
||||
</label>
|
||||
|
||||
<label>Image URL (https) – optional if you upload a file
|
||||
<input type="url" name="image" value="{{ (data.image if data else '') | default('') }}" />
|
||||
</label>
|
||||
|
||||
<label>Or upload image
|
||||
<input type="file" name="image_file" accept="image/*" />
|
||||
</label>
|
||||
|
||||
{% if data and data.image %}
|
||||
<div>
|
||||
<small>Current image:</small><br>
|
||||
<img src="{{ data.image }}" alt="{{ data.name }}" class="icon" style="width:64px;height:64px;object-fit:cover;border-radius:8px;"/>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<label>App URL (https)
|
||||
<input type="url" name="url" value="{{ (data.url if data else '') | default('') }}" required />
|
||||
</label>
|
||||
|
||||
<label>Priority
|
||||
<input type="number" name="priority" value="{{ (data.priority if data else 0) | default(0) }}" />
|
||||
</label>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" {% if not data or data.enabled %}checked{% endif %}/> Enabled
|
||||
</label>
|
||||
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="{{ url_for('apps_list') }}">Cancel</a>
|
||||
<button class="btn primary" type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
38
templates/apps_list.html
Normal file
38
templates/apps_list.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Apps – LaunchPad Admin{% endblock %}
|
||||
{% block content %}
|
||||
<div class="header-row">
|
||||
<h1>Apps</h1>
|
||||
<a class="btn" href="{{ url_for('app_create') }}">+ Add App</a>
|
||||
</div>
|
||||
|
||||
{% if apps %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>Icon</th><th>Name</th><th>URL</th><th>Enabled</th><th>Created</th><th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in apps %}
|
||||
<tr>
|
||||
<td>{{ a.id }}</td>
|
||||
<td>{% if a.image %}<img src="{{ a.image }}" alt="{{ a.name }}" class="icon"/>{% else %}-{% endif %}</td>
|
||||
<td>{{ a.name }}</td>
|
||||
<td><a href="{{ a.url }}" target="_blank" rel="noopener">{{ a.url }}</a></td>
|
||||
<td>{{ 'Yes' if a.enabled else 'No' }}</td>
|
||||
<td>{{ a.created_at }}</td>
|
||||
<td class="actions">
|
||||
<a class="btn small" href="{{ url_for('app_edit', app_id=a.id) }}">Edit</a>
|
||||
<form method="post" action="{{ url_for('app_delete', app_id=a.id) }}" onsubmit="return confirm('Delete this app?')" style="display:inline-block">
|
||||
<button class="btn danger small" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No apps yet. Click “Add App” to create your first one.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
33
templates/base.html
Normal file
33
templates/base.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}LaunchPad Admin{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LaunchPad Admin</div>
|
||||
<nav>
|
||||
{% if session.get('user') %}
|
||||
<a href="{{ url_for('apps_list') }}">Home</a>
|
||||
<a href="{{ url_for('logout') }}">Logout</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=True) %}
|
||||
{% if messages %}
|
||||
<div class="flash">
|
||||
{% for cat, msg in messages %}
|
||||
<div class="flash-item {{ cat }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
14
templates/login.html
Normal file
14
templates/login.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login – LaunchPad Admin{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Login</h1>
|
||||
<form method="post" class="form">
|
||||
<label>Username
|
||||
<input type="text" name="username" required autofocus />
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" required />
|
||||
</label>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user