feat: add self-contained link composer
Build and Push Docker Container / build-and-push (push) Successful in 1m36s
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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user