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\"";