From a72dcb154ae00782827bf2278b1d7ec1f8e50231 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Sun, 8 Feb 2026 11:55:20 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=B4=EA=B8=B0=20=EB=A7=81=ED=81=AC?= =?UTF-8?q?=EC=97=90=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=8B=9D=EB=B3=84?= =?UTF-8?q?=EA=B0=92=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /go 리다이렉트 엔드포인트 추가: 로그인 시 email, 비로그인 시 IP를 쿼리에 부착 - ncue.net 및 하위 도메인 링크에만 적용(안전한 allowlist) - script.js 및 index.html 폴백에서 열기 버튼을 /go 경유로 변경 Co-authored-by: Cursor --- flask_app.py | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++- index.html | 21 +++++++++++++++++- script.js | 22 +++++++++++++++++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/flask_app.py b/flask_app.py index 9527b22..e57b548 100644 --- a/flask_app.py +++ b/flask_app.py @@ -7,13 +7,14 @@ import time from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, Optional, Tuple +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse import jwt import psycopg2 import psycopg2.pool import requests from dotenv import load_dotenv -from flask import Flask, Response, jsonify, request, send_from_directory +from flask import Flask, Response, jsonify, redirect, request, send_from_directory from flask_cors import CORS @@ -248,6 +249,37 @@ ALLOWED_STATIC = { } +def client_ip() -> str: + # Prefer proxy header (Apache ProxyPass sets this) + xff = str(request.headers.get("X-Forwarded-For", "")).strip() + if xff: + return xff.split(",")[0].strip() + return str(request.remote_addr or "").strip() + + +def is_http_url(u: str) -> bool: + try: + p = urlparse(u) + return p.scheme in ("http", "https") and bool(p.netloc) + except Exception: + return False + + +def is_ncue_host(host: str) -> bool: + h = str(host or "").strip().lower() + return h == "ncue.net" or h.endswith(".ncue.net") + + +def add_ref_params(target_url: str, ref_type: str, ref_value: str) -> str: + p = urlparse(target_url) + q = dict(parse_qsl(p.query, keep_blank_values=True)) + # Keep names short + explicit + q["ref_type"] = ref_type + q["ref"] = ref_value + new_query = urlencode(q, doseq=True) + return urlunparse((p.scheme, p.netloc, p.path, p.params, new_query, p.fragment)) + + @app.get("/") def home() -> Response: return send_from_directory(ROOT_DIR, "index.html") @@ -261,6 +293,35 @@ def static_files(filename: str) -> Response: return send_from_directory(ROOT_DIR, filename) +@app.get("/go") +def go() -> Response: + """ + Redirect helper to pass identity to internal apps. + - Logged-in user: pass email (from query param) + - Anonymous user: pass client IP (server-side) + + For safety, only redirects to ncue.net / *.ncue.net targets. + """ + u = str(request.args.get("u", "")).strip() + if not u or not is_http_url(u): + return jsonify({"ok": False, "error": "invalid_url"}), 400 + + p = urlparse(u) + if not is_ncue_host(p.hostname or ""): + return jsonify({"ok": False, "error": "host_not_allowed"}), 400 + + email = str(request.args.get("e", "") or request.args.get("email", "")).strip().lower() + if email: + target = add_ref_params(u, "email", email) + else: + ip = client_ip() + target = add_ref_params(u, "ip", ip) + + resp = redirect(target, code=302) + resp.headers["Cache-Control"] = "no-store" + return resp + + @app.get("/healthz") def healthz() -> Response: try: diff --git a/index.html b/index.html index f215902..71a3a45 100644 --- a/index.html +++ b/index.html @@ -516,8 +516,27 @@ const favTag = link.favorite ? `★ 즐겨찾기` : ""; const lockTag = accessible ? "" : `접근 제한`; const letter = esc((link.title || d || "L").trim().slice(0, 1).toUpperCase()); + function buildOpenUrl(rawUrl) { + const url0 = String(rawUrl || "").trim(); + if (!url0) return ""; + let host = ""; + try { + host = new URL(url0).hostname.toLowerCase(); + } catch { + return url0; + } + const isNcue = host === "ncue.net" || host.endsWith(".ncue.net"); + if (!isNcue) return url0; + const qs = new URLSearchParams(); + qs.set("u", url0); + const email = String(sessionEmail || "").trim().toLowerCase(); + if (email) qs.set("e", email); + return `/go?${qs.toString()}`; + } + + const openHref = esc(buildOpenUrl(link.url)); const openHtml = accessible - ? `열기` + ? `열기` : ``; const copyAttrs = accessible ? "" : ` disabled aria-disabled="true" title="이 링크는 현재 권한으로 접근할 수 없습니다."`; return ` diff --git a/script.js b/script.js index 7d3098b..c6edfc8 100644 --- a/script.js +++ b/script.js @@ -98,6 +98,25 @@ return ACCESS_ANON_IDS.has(id); } + function buildOpenUrl(rawUrl) { + const url = String(rawUrl || "").trim(); + if (!url) return ""; + let host = ""; + try { + host = new URL(url).hostname.toLowerCase(); + } catch { + return url; + } + const isNcue = host === "ncue.net" || host.endsWith(".ncue.net"); + if (!isNcue) return url; + + const email = getUserEmail(); + const qs = new URLSearchParams(); + qs.set("u", url); + if (email) qs.set("e", email); + return `/go?${qs.toString()}`; + } + const auth = { client: null, user: null, @@ -308,8 +327,9 @@ const accessDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\""; const accessDisabledTitle = accessible ? "" : " title=\"이 링크는 현재 권한으로 접근할 수 없습니다.\""; + const openHref = escapeHtml(buildOpenUrl(link.url)); const openHtml = accessible - ? `열기` + ? `열기` : ``; const copyDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\"";