From 3b1dcec778ee2777587b78267484671dcfd24759 Mon Sep 17 00:00:00 2001 From: octospacc Date: Mon, 14 Jul 2025 22:52:22 +0200 Subject: [PATCH] Add WebManifest, base social metadata, noindex on non-pages; Add /api/items and adapt storing to support it; Improve auth handling and add session verification; Fix #11; Improve editing page with image paste from clipboard and file upload preview; Add footer; Autogenerate config --- _util.py | 11 ++ app.py | 167 ++++++++++++++++++------- static/add.js | 57 +++++++++ templates/add.html | 31 +---- templates/base.html | 55 +++++--- templates/{remove.html => delete.html} | 4 +- templates/item-card.html | 14 ++- templates/item.html | 22 ++-- templates/manifest.json | 5 + 9 files changed, 262 insertions(+), 104 deletions(-) create mode 100644 _util.py create mode 100644 static/add.js rename templates/{remove.html => delete.html} (80%) create mode 100644 templates/manifest.json diff --git a/_util.py b/_util.py new file mode 100644 index 0000000..8a740c0 --- /dev/null +++ b/_util.py @@ -0,0 +1,11 @@ +def read_textual(filepath:str) -> str: + try: + with open(filepath, "r", encoding="utf-8") as f: + return f.read() + except UnicodeDecodeError: + with open(filepath, "r") as f: + return f.read() + +def write_textual(filepath:str, content:str): + with open(filepath, "w", encoding="utf-8") as f: + return f.write(content) diff --git a/app.py b/app.py index 5cb189f..0820f4d 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,13 @@ import os import requests import urllib.parse -from typing import Any +from functools import wraps +from typing import Any, cast +from base64 import b64decode, urlsafe_b64encode from io import StringIO from configparser import ConfigParser from bs4 import BeautifulSoup -from flask import Flask, request, redirect, render_template, send_from_directory, abort, url_for, flash +from flask import Flask, request, redirect, render_template, send_from_directory, abort, url_for, flash, session, make_response from flask_bcrypt import Bcrypt # type: ignore[import-untyped] from flask_login import LoginManager, UserMixin, current_user, login_user, logout_user, login_required # type: ignore[import-untyped] from flask_wtf import FlaskForm # type: ignore[import-untyped] @@ -15,19 +17,35 @@ from glob import glob from pathlib import Path from datetime import datetime from snowflake import Snowflake, SnowflakeGenerator # type: ignore[import-untyped] +from hashlib import sha256 +from _util import * -SECRET_KEY = "SECRET_KEY" # import secrets; print(secrets.token_urlsafe()) -DEVELOPMENT = True +# config # +DEVELOPMENT = False HTTP_PORT = 5000 HTTP_THREADS = 32 LINKS_PREFIX = "" +# endconfig # -from _config import * +try: + from _config import * +except ModuleNotFoundError: + # print("Configuration file not found! Generating...") + from secrets import token_urlsafe + config = read_textual(__file__).split("# config #")[1].split("# endconfig #")[0].strip() + write_textual("_config.py", f"""\ +SECRET_KEY = "{token_urlsafe()}" +{config} +""") + # print("Saved configuration to _config.py. Exiting!") + # exit() + from _config import * app = Flask(__name__) app.config["LINKS_PREFIX"] = LINKS_PREFIX app.config["APP_NAME"] = "Pignio" app.config["APP_ICON"] = "📌" +app.config["DEVELOPMENT"] = DEVELOPMENT app.config["SECRET_KEY"] = SECRET_KEY app.config["BCRYPT_HANDLE_LONG_PASSWORDS"] = True @@ -43,8 +61,9 @@ DATA_ROOT = "data" ITEMS_ROOT = f"{DATA_ROOT}/items" USERS_ROOT = f"{DATA_ROOT}/users" EXTENSIONS = { - "images": ("jpg", "jpeg", "png", "gif", "webp", "avif"), - "videos": ("mp4", "mov", "mpeg", "ogv", "webm", "mkv"), + "image": ("jpg", "jpeg", "png", "gif", "webp", "avif"), + "video": ("mp4", "mov", "mpeg", "ogv", "webm", "mkv"), + "audio": ("mp3", "m4a", "flac", "opus", "ogg", "wav"), } ITEMS_EXT = ".ini" @@ -62,11 +81,27 @@ class LoginForm(FlaskForm): password = PasswordField("Password", validators=[DataRequired()]) submit = SubmitField("Login") +def noindex(view_func): + @wraps(view_func) + def wrapped_view(*args, **kwargs): + response = make_response(view_func(*args, **kwargs)) + response.headers["X-Robots-Tag"] = "noindex" + return response + return wrapped_view + @app.route("/") def index(): return render_template("index.html", items=walk_items()) +@app.route("/manifest.json") +@noindex +def serve_manifest(): + response = make_response(render_template("manifest.json")) + response.headers["Content-Type"] = "application/json" + return response + @app.route("/static/module//") +@noindex def serve_module(module:str, filename:str): return send_from_directory(os.path.join("node_modules", module, "dist"), filename) @@ -105,6 +140,7 @@ def search(): return render_template("search.html", items=(results if found else None), query=query) @app.route("/add", methods=["GET", "POST"]) +@noindex @login_required def add_item(): item = {} @@ -116,22 +152,22 @@ def add_item(): elif request.method == "POST": iid = request.form.get("id") or generate_iid() - data = {key: request.form[key] for key in ["link", "title", "description", "image", "text"]} - if store_item(iid, data, request.files): + if store_item(iid, request.form, request.files): return redirect(url_for("view_item", iid=iid)) else: flash("Cannot save item", "danger") return render_template("add.html", item=item) -@app.route("/remove", methods=["GET", "POST"]) +@app.route("/delete", methods=["GET", "POST"]) +@noindex @login_required def remove_item(): if request.method == "GET": if (iid := request.args.get("item")): if (item := load_item(iid)): - return render_template("remove.html", item=item) + return render_template("delete.html", item=item) elif request.method == "POST": if (iid := request.form.get("id")): @@ -141,16 +177,12 @@ def remove_item(): abort(404) -@app.route("/api/preview") -@login_required -def link_preview(): - return fetch_url_data(request.args.get("url")) - @app.errorhandler(404) def error_404(e): return render_template("404.html"), 404 @app.route("/login", methods=["GET", "POST"]) +@noindex def login(): if current_user.is_authenticated: return redirect(url_for("index")) @@ -166,6 +198,7 @@ def login(): if pass_equals: user.data["password"] = bcrypt.generate_password_hash(user.data["password"]).decode("utf-8") write_textual(user.filepath, write_metadata(user.data)) + session["session_hash"] = generate_user_hash(user.username, user.data["password"]) login_user(user) # next_url = flask.request.args.get('next') # if not url_has_allowed_host_and_scheme(next_url, request.host): return flask.abort(400) @@ -176,17 +209,58 @@ def login(): return render_template("login.html", form=form) @app.route("/logout") +@noindex def logout(): if current_user.is_authenticated: logout_user() return redirect(url_for("index")) +@app.route("/api/preview") +@noindex +@login_required +def link_preview(): + return fetch_url_data(request.args.get("url")) + +@app.route("/api/items", defaults={'iid': None}, methods=["POST"]) +@app.route("/api/items/", methods=["GET", "PUT", "DELETE"]) +@noindex +@login_required +def items_api(iid:str): + if request.method == "GET": + return load_item(iid) + elif request.method == "POST" or request.method == "PUT": + iid = iid or generate_iid() + status = store_item(iid, request.get_json()) + return {"id": iid if status else None} + elif request.method == "DELETE": + delete_item(iid) + return {} + @login_manager.user_loader def load_user(username:str): filepath = os.path.join(USERS_ROOT, (username + ITEMS_EXT)) if os.path.exists(filepath): return User(username, filepath) +@login_manager.unauthorized_handler +def unauthorized(): + if request.path.startswith("/api/"): + return {"error": "Unauthorized"}, 401 + else: + flash("Please log in to access this page.") + return redirect(url_for("login")) + +@app.before_request +def validate_session(): + if current_user.is_authenticated: + expected_hash = generate_user_hash(current_user.username, current_user.data["password"]) + if session.get("session_hash") != expected_hash: + logout_user() + +def generate_user_hash(username:str, password:str): + text = f"{username}:{password}" + return urlsafe_b64encode(sha256(text.encode()).digest()).decode() + def walk_items(): results, iids = {}, {} @@ -257,33 +331,45 @@ def load_item(iid:str): if file.lower().endswith(ITEMS_EXT): data = data | read_metadata(read_textual(file)) - elif file.lower().endswith(tuple([f".{ext}" for ext in EXTENSIONS["images"]])): + elif file.lower().endswith(tuple([f".{ext}" for ext in EXTENSIONS["image"]])): data["image"] = file.replace(os.sep, "/").removeprefix(f"{ITEMS_ROOT}/") return data -def store_item(iid:str, data:dict, files:dict): +def store_item(iid:str, data:dict, files:dict|None=None): iid = filename_to_iid(iid) existing = load_item(iid) filename = split_iid(iid_to_filename(iid)) filepath = os.path.join(ITEMS_ROOT, *filename) mkdirs(os.path.join(ITEMS_ROOT, filename[0])) image = False + data = {key: data[key] for key in ["link", "title", "description", "image", "text"] if key in data} - if len(files): + if files and len(files): file = files["file"] if file.seek(0, os.SEEK_END): file.seek(0, os.SEEK_SET) - ext = file.content_type.split("/")[1] - file.save(f"{filepath}.{ext}") - image = True - if not image and data["image"]: - response = requests.get(data["image"], timeout=5) - ext = response.headers["Content-Type"].split("/")[1] - with open(f"{filepath}.{ext}", "wb") as f: - f.write(response.content) - image = True - if not (existing or image or data["text"]): + mime = file.content_type.split("/") + ext = mime[1] + if mime[0] == "image" and ext in EXTENSIONS["image"]: + file.save(f"{filepath}.{ext}") + image = True + if not image and "image" in data and data["image"]: + if data["image"].lower().startswith("data:image/"): + ext = data["image"].lower().split(";")[0].split("/")[1] + if ext in EXTENSIONS["image"]: + with open(f"{filepath}.{ext}", "wb") as f: + f.write(b64decode(data["image"].split(",")[1])) + image = True + else: + response = requests.get(data["image"], timeout=5) + mime = response.headers["Content-Type"].split("/") + ext = mime[1] + if mime[0] == "image" and ext in EXTENSIONS["image"]: + with open(f"{filepath}.{ext}", "wb") as f: + f.write(response.content) + image = True + if not (existing or image or ("text" in data and data["text"])): return False if existing: @@ -299,8 +385,9 @@ def store_item(iid:str, data:dict, files:dict): write_textual(filepath + ITEMS_EXT, write_metadata(data)) return True -def delete_item(item:dict): - filepath = os.path.join(ITEMS_ROOT, iid_to_filename(item["id"])) +def delete_item(item:dict|str): + iid = cast(str, item["id"] if type(item) == dict else item) + filepath = os.path.join(ITEMS_ROOT, iid_to_filename(iid)) files = glob(f"{filepath}.*") for file in files: os.remove(file) @@ -327,18 +414,6 @@ def write_metadata(data:dict) -> str: config.write(output) return "\n".join(output.getvalue().splitlines()[1:]) # remove section header -def read_textual(filepath:str) -> str: - try: - with open(filepath, "r", encoding="utf-8") as f: - return f.read() - except UnicodeDecodeError: - with open(filepath, "r") as f: - return f.read() - -def write_textual(filepath:str, content:str): - with open(filepath, "w", encoding="utf-8") as f: - return f.write(content) - def fetch_url_data(url:str): response = requests.get(url, timeout=5) soup = BeautifulSoup(response.text, "html.parser") @@ -362,11 +437,9 @@ def fetch_url_data(url:str): "link": soup_or_default(soup, "link", {"rel": "canonical"}, "href", url), } -def prop_or_default(items:Any, prop:str, default): - return (items[prop] if (items and prop in items) else None) or default - def soup_or_default(soup:BeautifulSoup, tag:str, attrs:dict, prop:str, default): - return prop_or_default(soup.find(tag, attrs=attrs), prop, default) + elem = soup.find(tag, attrs=attrs) + return (elem.get(prop) if elem else None) or default # type: ignore[attr-defined] def generate_iid() -> str: return str(next(snowflake)) diff --git a/static/add.js b/static/add.js new file mode 100644 index 0000000..2d5f5d9 --- /dev/null +++ b/static/add.js @@ -0,0 +1,57 @@ +var link = document.querySelector('form input[name="link"]'); +var check = document.querySelector('form input[type="checkbox"]'); +var image = document.querySelector('form img.image'); +var upload = document.querySelector('form input[name="file"]'); + +upload.addEventListener('change', function(ev) { + const file = ev.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = function(e) { + image.src = e.target.result; + image.parentElement.hidden = false; + }; + reader.readAsDataURL(file); +}); + +document.addEventListener('paste', function(ev) { + const items = (ev.clipboardData || ev.originalEvent.clipboardData).items; + for (let item of items) { + if (item.type.indexOf('image') !== -1) { + const file = item.getAsFile(); + const reader = new FileReader(); + reader.onload = function(e) { + image.src = e.target.result; + image.parentElement.hidden = false; + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + upload.files = dataTransfer.files; + }; + reader.readAsDataURL(file); + break; + } + } +}); + +['change', 'input', 'paste'].forEach(handler => { + link.addEventListener(handler, () => { + var url = link.value.trim(); + if (check.checked && url) { + fetch('../api/preview?url=' + encodeURIComponent(url)) + .then(res => res.json()) + .then(data => { + for (var key in data) { + var field = document.querySelector(`form [name="${key}"]`); + if (field) { + field.value = data[key]; + } + var el = document.querySelector(`form [class="${key}"]`); + if (el) { + el.src = data[key]; + el.parentElement.hidden = false; + } + } + }) + } + }) +}); \ No newline at end of file diff --git a/templates/add.html b/templates/add.html index 08289ab..c6b760f 100644 --- a/templates/add.html +++ b/templates/add.html @@ -24,10 +24,10 @@
-
+
- +
@@ -50,30 +50,5 @@
- + {% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index fbae450..cd7d4c5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,15 +11,25 @@ {% if canonical %} + {% endif %} + + {% block metadata %}{% endblock %} + + - + -
- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -

{{ message }}

- {% endfor %} - {% endif %} - {% endwith %} - {% block content %}{% endblock %} +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +

{{ message }}

+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ \ No newline at end of file diff --git a/templates/remove.html b/templates/delete.html similarity index 80% rename from templates/remove.html rename to templates/delete.html index 791f48e..cd6bbc5 100644 --- a/templates/remove.html +++ b/templates/delete.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}🗑 Remove item{% endblock %} +{% block title %}🗑 Delete item{% endblock %} {% block content %}
@@ -8,7 +8,7 @@ Back
- +
diff --git a/templates/item-card.html b/templates/item-card.html index 4ee356e..f54fce3 100644 --- a/templates/item-card.html +++ b/templates/item-card.html @@ -1,6 +1,8 @@ -
- - {% include "item-content.html" %} - {% include "item-title.html" %} - -
\ No newline at end of file +{% if item %} +
+ + {% include "item-content.html" %} + {% include "item-title.html" %} + +
+{% endif %} \ No newline at end of file diff --git a/templates/item.html b/templates/item.html index 72de372..d2e7a01 100644 --- a/templates/item.html +++ b/templates/item.html @@ -1,6 +1,18 @@ {% extends "base.html" %} {% block title %}{% include "item-title.html" %}{% endblock %} {% block canonical %}{{ url_for('view_item', iid=item.id) }}{% endblock %} +{% block metadata %} + + + + + + {% if item.image %} + + + + {% endif %} +{% endblock %} {% block content %}
@@ -15,18 +27,14 @@

{{ item.description }}

diff --git a/templates/manifest.json b/templates/manifest.json new file mode 100644 index 0000000..551d715 --- /dev/null +++ b/templates/manifest.json @@ -0,0 +1,5 @@ +{ + "name": "{{ config.APP_NAME }}", + "start_url": "{{ url_for('index') }}", + "display": "standalone" +} \ No newline at end of file