feat: add self-contained link composer
Build and Push Docker Container / build-and-push (push) Successful in 1m36s

- Add structured URL codec with compact alphabets and dictionary support.
- Add public decode, redirect, and QR endpoints for composed links.
- Add authenticated encode API and link composer UI.
- Generate PNG QR codes via qrcode with Pillow support.
- Register the new blueprint and navigation entry.
- Cover public decode, auth gating, redirect URL parsing, and QR output.
- Bump NanoShare to 1.27.0 and lock new dependencies.
This commit is contained in:
2026-08-14 08:56:42 +02:00
parent d3e5e11f8d
commit ed62837f6d
18 changed files with 1139 additions and 163 deletions
+1
View File
@@ -18,6 +18,7 @@ auth_login_bp = create_auth_login_blueprint(
from .side.main import side_main_bp
from .side.upload import upload_bp
from .side.link_composer import link_composer_bp
from .api.cli_auth import cli_auth_bp
from .api.download import api_download_bp
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
from urllib.parse import quote, unquote, urlparse
from quart import Blueprint, Response, abort, jsonify, render_template, request, session, url_for
from link_composer.codec import decode_url, encode_url
from link_composer.constants import ALLOWED_REDIRECT_SCHEMES
from link_composer.qr import make_qr_png
from link_composer.security import validate_redirect_url
from my_modules.app.setup import LIMITER
from my_modules.decoratory.header import login_required
from quart_common.web.wide_event import add_wide_event_context
link_composer_bp = Blueprint("link_composer", __name__)
LINK_REDIRECT_PREFIX = "/l/"
LINK_QR_PREFIX = "/links/qr/"
def _external_url(endpoint: str, **values: str) -> str:
return request.url_root.rstrip("/") + url_for(endpoint, **values)
def _external_code_url(prefix: str, code: str) -> str:
return request.url_root.rstrip("/") + prefix + quote(code, safe="")
def _extract_code(value: str) -> str:
raw_value = value.strip()
parsed = urlparse(raw_value)
if parsed.scheme in ALLOWED_REDIRECT_SCHEMES:
path = parsed.path + (f";{parsed.params}" if parsed.params else "")
if path.startswith(LINK_REDIRECT_PREFIX):
return unquote(path.removeprefix(LINK_REDIRECT_PREFIX))
if path.startswith(LINK_QR_PREFIX):
return unquote(path.removeprefix(LINK_QR_PREFIX))
return raw_value
def _decode_or_400(code: str) -> str:
try:
return validate_redirect_url(decode_url(_extract_code(code)))
except ValueError:
abort(400)
@link_composer_bp.get("/links")
@LIMITER.limit("30 per minute;500 per hour;")
async def link_composer_page():
add_wide_event_context(nanoshare={"operation": "link_composer_page"})
return await render_template(
"views/webpage/links/composer.htm",
can_encode=session.get("user") is not None,
encode_api_url=url_for("link_composer.encode_link_api"),
decode_api_url=url_for("link_composer.decode_link_api"),
redirect_prefix=LINK_REDIRECT_PREFIX,
qr_prefix=LINK_QR_PREFIX,
)
@link_composer_bp.post("/api/links/encode")
@LIMITER.limit("30 per minute;500 per hour;")
@login_required
async def encode_link_api(user):
add_wide_event_context(nanoshare={"operation": "link_encode", "user_id": user.get("sub")})
payload = await request.get_json(silent=True) or {}
url = str(payload.get("url") or "").strip()
qr_mode = bool(payload.get("qr"))
try:
result = encode_url(url, qr=qr_mode)
decoded_url = validate_redirect_url(decode_url(result.code))
except ValueError as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
redirect_url = _external_code_url(LINK_REDIRECT_PREFIX, result.code)
qr_url = _external_code_url(LINK_QR_PREFIX, result.code)
return jsonify({
"ok": True,
"code": result.code,
"url": decoded_url,
"redirect_url": redirect_url,
"qr_url": qr_url,
"original_length": result.original_length,
"code_length": result.code_length,
"bit_length": result.bit_length,
"mode": result.mode,
"ratio": round(result.code_length / result.original_length, 3),
})
@link_composer_bp.post("/api/links/decode")
@LIMITER.limit("60 per minute;1000 per hour;")
async def decode_link_api():
add_wide_event_context(nanoshare={"operation": "link_decode"})
payload = await request.get_json(silent=True) or {}
code = _extract_code(str(payload.get("code") or ""))
try:
url = validate_redirect_url(decode_url(code))
except ValueError as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
return jsonify({"ok": True, "url": url})
@link_composer_bp.get(f"{LINK_REDIRECT_PREFIX}<path:code>")
@LIMITER.limit("120 per minute;2000 per hour;")
async def redirect_link(code: str):
add_wide_event_context(nanoshare={"operation": "link_redirect"})
url = _decode_or_400(code)
return Response(status=307, headers={"Location": url, "Cache-Control": "no-store"})
@link_composer_bp.get(f"{LINK_QR_PREFIX}<path:code>")
@LIMITER.limit("60 per minute;1000 per hour;")
async def qr_link(code: str):
add_wide_event_context(nanoshare={"operation": "link_qr"})
_decode_or_400(code)
redirect_url = _external_code_url(LINK_REDIRECT_PREFIX, code)
return Response(make_qr_png(redirect_url), content_type="image/png")