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

This commit is contained in:
2025-07-14 22:52:22 +02:00
parent 0a8f5269e4
commit 3b1dcec778
9 changed files with 262 additions and 104 deletions

11
_util.py Normal file
View File

@ -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)

167
app.py
View File

@ -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/<path:module>/<path:filename>")
@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/<path:iid>", 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))

57
static/add.js Normal file
View File

@ -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;
}
}
})
}
})
});

View File

@ -24,10 +24,10 @@
</label>
</div>
<div class="uk-margin">
<div uk-grid>
<div class="uk-grid-collapse" uk-grid>
<div uk-form-custom class="uk-width-1-1">
<input class="uk-input" type="file" name="file" />
<button class="uk-button uk-width-1-1 uk-button-default" type="button" tabindex="-1">Select image</button>
<button class="uk-button uk-width-1-1 uk-button-default" type="button" tabindex="-1">Select or drop an image file</button>
</div>
</div>
</div>
@ -50,30 +50,5 @@
<button class="uk-input uk-button uk-button-primary" type="submit">{{ ("Edit " + item.id) if item.id else "Add" }}</button>
</div>
</form>
<script>
var link = document.querySelector('form input[name="link"]');
var check = document.querySelector('form input[type="checkbox"]');
['change', 'input', 'paste'].forEach(handler => {
link.addEventListener(handler, () => {
var url = link.value.trim();
if (check.checked && url) {
fetch('{{ url_for("link_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;
}
}
})
}
})
});
</script>
<script src="{{ url_for('static', filename='add.js') }}"></script>
{% endblock %}

View File

@ -11,15 +11,25 @@
<script src="{{ url_for('serve_module', module='uikit', filename='js/uikit-icons.min.js') }}"></script>
{% if canonical %}
<link rel="canonical" href="{{ config.LINKS_PREFIX }}{{ canonical }}" />
<meta name="og:url" content="{{ config.LINKS_PREFIX }}{{ canonical }}" />
{% endif %}
<meta name="og:site_name" content="{{ config.APP_NAME }}" />
{% block metadata %}{% endblock %}
<link rel="manifest" href="{{ url_for('serve_manifest') }}" />
<meta name="generator" content="{{ config.APP_NAME }}" />
</head>
<body>
<body class="uk-height-viewport uk-flex uk-flex-column">
<nav class="uk-navbar-container" uk-sticky="sel-target: .uk-navbar-container; cls-active: uk-navbar-sticky">
<div class="uk-container uk-container-expand" uk-navbar>
<div class="uk-navbar-left uk-width-expand">
<a class="uk-logo" href="{{ url_for('index') }}">
<span uk-icon="icon: home"></span>
{{ config.APP_NAME }}
<a class="uk-link-reset" href="{{ url_for('index') }}">
<span class="uk-logo">
<span uk-icon="icon: home"></span>
{{ config.APP_NAME }}
</span>
{%if config.DEVELOPMENT %}
<span class="uk-text-small">DEV</span>
{% endif %}
</a>
<form class="uk-search uk-search-navbar uk-width-auto uk-flex-1" action="{{ url_for('search') }}">
<input class="uk-search-input" type="search" name="query" placeholder="Search..." value="{{ query }}" required>
@ -27,21 +37,38 @@
</form>
</div>
<div class="uk-navbar-right uk-margin-left">
<a class="uk-button uk-button-default uk-icon-link" uk-icon="plus" href="{{ url_for('add_item') }}">
<a class="uk-button uk-button-default uk-icon-link" uk-icon="plus" href="{{ url_for('add_item') }}" rel="nofollow">
<span class="uk-visible@s">Create</span>
</a>
</div>
</div>
</nav>
<div class="uk-container uk-margin">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<p uk-alert class="uk-alert-{{ category }}">{{ message }}</p>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
<div class="uk-flex uk-flex-center uk-margin uk-flex-auto">
<div class="uk-container uk-flex-auto">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<p uk-alert class="uk-alert-{{ category }}">{{ message }}</p>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</div>
<footer class="uk-section uk-section-xsmall uk-background-muted">
<div class="uk-container">
<p class="uk-text-meta">
Powered by <a class="uk-text-primary" target="_blank" href="https://gitlab.com/octospacc/Pignio">{{ config.APP_NAME }}</a>, © 2025 OctoSpacc
<span class="uk-float-right">
{% if current_user.is_authenticated %}
Logged in as <a href="{{ url_for('view_user', username=current_user.username) }}">{{ current_user.username }}</a>
<a class="uk-button uk-button-text uk-text-baseline" href="{{ url_for('logout') }}">Logout</a>
{% else %}
<a class="uk-button uk-button-text uk-text-baseline" href="{{ url_for('login') }}">Login</a>
{% endif %}
</span>
</p>
</div>
</footer>
</body>
</html>

View File

@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}🗑 Remove item{% endblock %}
{% block title %}🗑 Delete item{% endblock %}
{% block content %}
<form method="POST">
<input type="hidden" name="id" value="{{ item.id }}" />
@ -8,7 +8,7 @@
<a class="uk-button uk-button-default" href="{{ url_for('view_item', iid=item.id) }}">Back</a>
</div>
<div>
<button class="uk-input uk-button uk-button-danger" type="submit">Remove {{ item.id }}</button>
<button class="uk-input uk-button uk-button-danger" type="submit">Delete {{ item.id }}</button>
</div>
</div>
</form>

View File

@ -1,6 +1,8 @@
<div>
<a class="uk-link-text" href="{{ url_for('view_item', iid=item.id) }}">
{% include "item-content.html" %}
<span class="uk-text-break">{% include "item-title.html" %}</span>
</a>
</div>
{% if item %}
<div>
<a class="uk-link-text" href="{{ url_for('view_item', iid=item.id) }}">
{% include "item-content.html" %}
<span class="uk-text-break">{% include "item-title.html" %}</span>
</a>
</div>
{% endif %}

View File

@ -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 %}
<meta name="og:title" content="{{ title }}" />
<meta name="twitter:title" content="{{ title }}" />
<meta name="og:description" content="{{ item.description }}" />
<meta name="twitter:description" content="{{ item.description }}" />
<meta name="description" content="{{ item.description }}" />
{% if item.image %}
<meta name="og:image" content="{{ url_for('serve_media', filename=item.image) }}" />
<meta name="twitter:image" content="{{ url_for('serve_media', filename=item.image) }}" />
<meta name="twitter:card" content="summary_large_image" />
{% endif %}
{% endblock %}
{% block content %}
<div class="uk-flex uk-flex-wrap">
<div class="uk-width-1-1 uk-width-1-2@s uk-padding-small">
@ -15,18 +27,14 @@
</div>
<p>{{ item.description }}</p>
<div class="uk-margin">
<a class="uk-button uk-button-default uk-icon-link" href="{{ url_for('add_item') }}?item={{ item.id }}">
<a class="uk-button uk-button-default uk-icon-link" href="{{ url_for('add_item') }}?item={{ item.id }}" rel="nofollow">
Edit
<span uk-icon="icon: file-edit"></span>
</a>
<a class="uk-button uk-button-danger" href="{{ url_for('remove_item') }}?item={{ item.id }}">
Remove
<a class="uk-button uk-button-danger" href="{{ url_for('remove_item') }}?item={{ item.id }}" rel="nofollow">
Delete
<span uk-icon="icon: trash"></span>
</a>
<!-- <form class="uk-inline" method="POST" action="{{ url_for('remove_item') }}">
<input type="hidden" name="item" value="{{ item.id }}" />
<button class="uk-input uk-button uk-button-danger" type="submit">Remove</button>
</form> -->
</div>
</div>
</div>

5
templates/manifest.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "{{ config.APP_NAME }}",
"start_url": "{{ url_for('index') }}",
"display": "standalone"
}