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