diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..615b88f --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +ADMIN_PASSWORD=change-me +SECRET_KEY=change-me-to-a-long-random-string +COOKIE_SECURE=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cff5543 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +*.pyc diff --git a/app.py b/app.py new file mode 100644 index 0000000..e29ceb4 --- /dev/null +++ b/app.py @@ -0,0 +1,249 @@ +import json +import os +import re +import secrets +from functools import wraps +from pathlib import Path + +from flask import Flask, abort, redirect, render_template, request, session, url_for + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" +app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "true").lower() != "false" + +ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") +DATA_FILE = Path(os.environ.get("DATA_FILE", "data/links.json")) + +COLORS = ["blue", "green", "orange", "pink"] + + +def load_data(): + with open(DATA_FILE, "r", encoding="utf-8") as f: + return json.load(f) + + +def save_data(data): + tmp = DATA_FILE.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + tmp.replace(DATA_FILE) + + +def login_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + if not session.get("admin"): + return redirect(url_for("admin_dashboard")) + return view(*args, **kwargs) + + return wrapped + + +def get_page_or_404(data, page_key): + if page_key not in data: + abort(404) + return data[page_key] + + +def get_section_or_404(page, section_id): + for section in page["sections"]: + if section["id"] == section_id: + return section + abort(404) + + +@app.route("/") +def index(): + data = load_data() + return render_template("page.html", page=data["index"]) + + +@app.route("/.html") +def page(slug): + data = load_data() + page_data = data.get(slug) + if not page_data: + abort(404) + return render_template("page.html", page=page_data) + + +@app.route("/admin", methods=["GET"]) +def admin_dashboard(): + if not session.get("admin"): + return render_template("admin_login.html", error=None) + data = load_data() + return render_template("admin.html", data=data, colors=COLORS) + + +@app.route("/admin/login", methods=["POST"]) +def admin_login(): + if not ADMIN_PASSWORD: + return render_template( + "admin_login.html", error="ADMIN_PASSWORD ist nicht gesetzt (siehe .env)." + ), 500 + if secrets.compare_digest(request.form.get("password", ""), ADMIN_PASSWORD): + session["admin"] = True + return redirect(url_for("admin_dashboard")) + return render_template("admin_login.html", error="Falsches Passwort"), 401 + + +@app.route("/admin/logout", methods=["POST"]) +def admin_logout(): + session.clear() + return redirect(url_for("admin_dashboard")) + + +@app.route("/admin//header", methods=["POST"]) +@login_required +def edit_header(page_key): + data = load_data() + page = get_page_or_404(data, page_key) + page["title"] = request.form.get("title", page.get("title", "")).strip() + page["subtitle"] = request.form.get("subtitle", page.get("subtitle", "")).strip() + page["subtitle2"] = request.form.get("subtitle2", page.get("subtitle2", "")).strip() + page["footer_label"] = request.form.get("footer_label", page.get("footer_label", "")).strip() + page["footer_href"] = request.form.get("footer_href", page.get("footer_href", "")).strip() + save_data(data) + return redirect(url_for("admin_dashboard")) + + +@app.route("/admin/page/add", methods=["POST"]) +@login_required +def add_page(): + data = load_data() + title = request.form.get("title", "").strip() + slug = request.form.get("slug", "").strip().lower() + slug = re.sub(r"[^a-z0-9-]+", "-", slug).strip("-") + if slug and title and slug not in data: + data[slug] = { + "title": title, + "subtitle": "", + "subtitle2": "", + "footer_label": "", + "footer_href": "", + "sections": [], + } + save_data(data) + return redirect(url_for("admin_dashboard")) + + +@app.route("/admin/page//delete", methods=["POST"]) +@login_required +def delete_page(page_key): + if page_key == "index": + abort(400) + data = load_data() + data.pop(page_key, None) + save_data(data) + return redirect(url_for("admin_dashboard")) + + +@app.route("/admin//section/add", methods=["POST"]) +@login_required +def add_section(page_key): + data = load_data() + page = get_page_or_404(data, page_key) + name = request.form.get("name", "").strip() + if name: + page["sections"].append({"id": secrets.token_hex(4), "name": name, "links": []}) + save_data(data) + return redirect(url_for("admin_dashboard")) + + +@app.route("/admin//section//rename", methods=["POST"]) +@login_required +def rename_section(page_key, section_id): + data = load_data() + page = get_page_or_404(data, page_key) + section = get_section_or_404(page, section_id) + name = request.form.get("name", "").strip() + if name: + section["name"] = name + save_data(data) + return redirect(url_for("admin_dashboard")) + + +@app.route("/admin//section//delete", methods=["POST"]) +@login_required +def delete_section(page_key, section_id): + data = load_data() + page = get_page_or_404(data, page_key) + page["sections"] = [s for s in page["sections"] if s["id"] != section_id] + save_data(data) + return redirect(url_for("admin_dashboard")) + + +@app.route("/admin//section//link/add", methods=["POST"]) +@login_required +def add_link(page_key, section_id): + data = load_data() + page = get_page_or_404(data, page_key) + section = get_section_or_404(page, section_id) + title = request.form.get("title", "").strip() + url = request.form.get("url", "").strip() + if title and url: + section["links"].append( + { + "id": secrets.token_hex(4), + "title": title, + "note": request.form.get("note", "").strip(), + "url": url, + "color": request.form.get("color", "blue"), + "half": bool(request.form.get("half")), + } + ) + save_data(data) + return redirect(url_for("admin_dashboard")) + + +@app.route("/admin//link//edit", methods=["POST"]) +@login_required +def edit_link(page_key, link_id): + data = load_data() + page = get_page_or_404(data, page_key) + for section in page["sections"]: + for link in section["links"]: + if link["id"] == link_id: + link["title"] = request.form.get("title", link["title"]).strip() + link["note"] = request.form.get("note", link["note"]).strip() + link["url"] = request.form.get("url", link["url"]).strip() + link["color"] = request.form.get("color", link["color"]) + link["half"] = bool(request.form.get("half")) + save_data(data) + return redirect(url_for("admin_dashboard")) + + +@app.route("/admin//link//delete", methods=["POST"]) +@login_required +def delete_link(page_key, link_id): + data = load_data() + page = get_page_or_404(data, page_key) + for section in page["sections"]: + section["links"] = [l for l in section["links"] if l["id"] != link_id] + save_data(data) + return redirect(url_for("admin_dashboard")) + + +@app.route("/admin//link//move", methods=["POST"]) +@login_required +def move_link(page_key, link_id): + data = load_data() + page = get_page_or_404(data, page_key) + direction = request.form.get("direction") + for section in page["sections"]: + ids = [l["id"] for l in section["links"]] + if link_id in ids: + idx = ids.index(link_id) + new_idx = idx - 1 if direction == "up" else idx + 1 + if 0 <= new_idx < len(section["links"]): + section["links"][idx], section["links"][new_idx] = ( + section["links"][new_idx], + section["links"][idx], + ) + save_data(data) + return redirect(url_for("admin_dashboard")) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=80) diff --git a/data/links.json b/data/links.json new file mode 100644 index 0000000..8c1bd96 --- /dev/null +++ b/data/links.json @@ -0,0 +1,184 @@ +{ + "index": { + "title": "Honey Services for Cinnamon", + "subtitle": "Welcome!", + "subtitle2": "Here are all my important links for you!", + "footer_label": "Cinnamon Version", + "footer_href": "honey.html", + "sections": [ + { + "id": "s_important", + "name": "Important Stuff", + "links": [ + { + "id": "l_crochet", + "title": "CrochetMeMaybe🧶", + "note": "Crochet Website Counting", + "url": "https://crochetmemaybe.asianxpolish.duckdns.org", + "color": "blue", + "half": false + }, + { + "id": "l_pixelpaper1", + "title": "PixelPaper📖", + "note": "EBook and Manga Server", + "url": "https://pixelpaper.asianxpolish.duckdns.org", + "color": "blue", + "half": false + }, + { + "id": "l_ticket1", + "title": "Ticket System 🎆", + "note": "Tickets an Honey", + "url": "https://hot.asianxpolish.duckdns.org", + "color": "blue", + "half": false + }, + { + "id": "l_learn1", + "title": "Learning Tracker✒️", + "note": "Tracker für das lernen und rewards!", + "url": "https://learn.asianxpolish.duckdns.org", + "color": "blue", + "half": false + }, + { + "id": "l_live", + "title": "Livestream!👾", + "note": "Dein Persönlicher Livestream", + "url": "https://live.asianxpolish.duckdns.org", + "color": "blue", + "half": false + } + ] + }, + { + "id": "s_working", + "name": "Working Stuff", + "links": [ + { + "id": "l_honeycloud", + "title": "Honeycloud🍯", + "note": "The HoneyCloud Service", + "url": "https://honeyycloud.duckdns.org", + "color": "green", + "half": false + }, + { + "id": "l_pocket1", + "title": "Pocket-ID📓", + "note": "Dein User in all den Systemen (Passkey)", + "url": "https://pocket.asianxpolish.duckdns.org", + "color": "green", + "half": false + } + ] + }, + { + "id": "s_tools1", + "name": "Tools", + "links": [ + { + "id": "l_pdf1", + "title": "PDF Tool📝", + "note": "PDF Editieren und Zusammenfügen", + "url": "https://pdf.asianxpolish.duckdns.org", + "color": "orange", + "half": false + } + ] + } + ] + }, + "honey": { + "title": "My Links", + "subtitle": "", + "subtitle2": "Some Stuff", + "footer_label": "Honey Version", + "footer_href": "index.html", + "sections": [ + { + "id": "s_pangolin", + "name": "Public with Pangolin", + "links": [ + { + "id": "l_pixelpaper2", + "title": "PixelPaper📖", + "note": "EBook and Manga Server", + "url": "https://pixelpaper.asianxpolish.duckdns.org", + "color": "orange", + "half": true + }, + { + "id": "l_catto", + "title": "Catto🐈‍⬛", + "note": "", + "url": "https://catto.asianxpolish.duckdns.org", + "color": "orange", + "half": true + } + ] + }, + { + "id": "s_tools2", + "name": "Tools", + "links": [ + { + "id": "l_pdf2", + "title": "PDF Tool📝", + "note": "PDF Editieren und Zusammenfügen", + "url": "https://pdf.asianxpolish.duckdns.org", + "color": "pink", + "half": false + }, + { + "id": "l_pocket2", + "title": "Pocket-ID", + "note": "OIDC Passkey", + "url": "https://pocket.asianxpolish.duckdns.org", + "color": "green", + "half": false + } + ] + }, + { + "id": "s_cinnamon", + "name": "For Cinnamon", + "links": [ + { + "id": "l_crochet2", + "title": "CrochetMeMaybe🧶", + "note": "Crochet Website Counting", + "url": "https://crochetmemaybe.asianxpolish.duckdns.org", + "color": "blue", + "half": false + }, + { + "id": "l_ticket2", + "title": "Ticket System 🎆", + "note": "", + "url": "https://hot.asianxpolish.duckdns.org/admin.html", + "color": "blue", + "half": false + }, + { + "id": "l_learn2", + "title": "Learning Tracker✒️", + "note": "", + "url": "https://learn.asianxpolish.duckdns.org", + "color": "blue", + "half": false + }, + { + "id": "32be2487", + "title": "Admin Page", + "note": "", + "url": "https://asianxpolish.duckdns.org/admin", + "color": "blue", + "half": false + } + ] + } + ] + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fa9cf89 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + honey-links: + build: . + ports: + - "8080:80" + environment: + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - SECRET_KEY=${SECRET_KEY} + # Set to "false" only for local http-only testing without a TLS proxy in front. + - COOKIE_SECURE=${COOKIE_SECURE:-true} + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/dockerfile b/dockerfile index f7465c6..46a1f54 100644 --- a/dockerfile +++ b/dockerfile @@ -1,16 +1,21 @@ # Use the official Python image -FROM python:3.12-slim +FROM python:3.14-slim # Set the working directory in the container WORKDIR /app -# Copy the HTML files into the container -COPY index.html /app -COPY honey.html /app -COPY styles.css /app +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the application into the container +COPY app.py . +COPY templates/ templates/ +COPY static/ static/ +COPY data/ data/ # Expose port 80 EXPOSE 80 -# Start the Python HTTP server on port 80 -CMD ["python3", "-m", "http.server", "80"]^ \ No newline at end of file +# Start the app via a production WSGI server +CMD ["waitress-serve", "--host=0.0.0.0", "--port=80", "app:app"] diff --git a/honey.html b/honey.html deleted file mode 100644 index afc67b9..0000000 --- a/honey.html +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - Honey | Links - - - - - -
- - -
-
-
-
-
-
-
-
- - - Profile Picture -

My Links

-

Some Stuff

- - -

Public with Pangolin

- - - - - - - - - - -

Tools

- - - - - - - - - - - - -

For Cinnamon

- - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/index.html b/index.html deleted file mode 100644 index 7849591..0000000 --- a/index.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - Honey | Links - - - - - -
- - -
-
-
-
-
-
-
-
- - - Profile Picture -

Honey Services for Cinnamon

-

Welcome!

-

Here are all my important links for you!

- - -

Important Stuff

- - - - - - - - - - - - - - - - - - - - - - - - - - -

Working Stuff

- - - - - - - - - - - -

Tools

- - - - - - - - - - diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..016897d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask>=3.0,<4 +waitress>=3.0,<4 diff --git a/styles.css b/static/styles.css similarity index 96% rename from styles.css rename to static/styles.css index 8a36099..c8c5bfe 100644 --- a/styles.css +++ b/static/styles.css @@ -144,11 +144,18 @@ animation: pulseGlow 3s ease-in-out infinite; } -.link { -display: block; +.links-grid { +display: flex; +flex-wrap: wrap; +gap: 10px; width: 100%; max-width: 320px; -margin: 6px 0; +margin-bottom: 14px; +} + +.link { +display: block; +flex: 0 0 100%; padding: 14px 24px; background-color: rgba(255, 255, 255, 0.04); color: #f3f4f6; @@ -160,6 +167,12 @@ font-weight: 600; text-align: left; } +/* Opt-in: two links next to each other instead of full width */ +.link.half { +flex: 0 0 calc(50% - 5px); +padding: 14px 14px; +} + .link:hover { background-color: rgba(255, 255, 255, 0.07); /* softer highlight */ color: #e0e7ff; /* gentle light blue tint */ @@ -307,6 +320,10 @@ will-change: opacity, transform, filter; font-size: 14px; padding: 12px 20px; } + + .link.half { + flex-basis: 100%; + } } @media (max-width: 768px) { diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..a8efbb9 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,209 @@ + + + + + + Admin – Honey Links + + + + +
+

Admin

+
+
+ +
+ {% for page_key in data.keys() %} + ↓ {{ page_key }} + {% endfor %} +
+ +
+

Neue Seite anlegen

+
+ + + +
+

Erreichbar danach unter /<slug>.html, z.B. /gaming.html.

+
+ + {% for page_key, page in data.items() %} +
+
+

+ Seite: {{ page_key }} — erreichbar unter {% if page_key == 'index' %}/{% else %}/{{ page_key }}.html{% endif %} + 🔗 ansehen +

+ {% if page_key != 'index' %} +
+ +
+ {% endif %} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + {% for section in page.sections %} +
+
+
+ + +
+
+ +
+
+ + {% for link in section.links %} + + {% endfor %} + +
+ + + + + + +
+
+ {% endfor %} + +
+ + +
+
+ {% endfor %} + + diff --git a/templates/admin_login.html b/templates/admin_login.html new file mode 100644 index 0000000..1c23223 --- /dev/null +++ b/templates/admin_login.html @@ -0,0 +1,52 @@ + + + + + + Admin Login + + + + +
+

Admin Login

+ {% if error %}
{{ error }}
{% endif %} +
+ + + +
+
+ + diff --git a/templates/page.html b/templates/page.html new file mode 100644 index 0000000..4988439 --- /dev/null +++ b/templates/page.html @@ -0,0 +1,50 @@ + + + + + + Honey | Links + + + + + +
+ + +
+
+
+
+
+
+
+
+ + + Profile Picture +

{{ page.title }}

+ {% if page.subtitle %}

{{ page.subtitle }}

{% endif %} + {% if page.subtitle2 %}

{{ page.subtitle2 }}

{% endif %} + + {% for section in page.sections %} +

{{ section.name }}

+ + {% endfor %} + + + + +