열기 링크에 사용자 식별값 전달

- /go 리다이렉트 엔드포인트 추가: 로그인 시 email, 비로그인 시 IP를 쿼리에 부착
- ncue.net 및 하위 도메인 링크에만 적용(안전한 allowlist)
- script.js 및 index.html 폴백에서 열기 버튼을 /go 경유로 변경

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-02-08 11:55:20 +09:00
parent d0a6b066b0
commit a72dcb154a
3 changed files with 103 additions and 3 deletions

View File

@@ -7,13 +7,14 @@ import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
import jwt import jwt
import psycopg2 import psycopg2
import psycopg2.pool import psycopg2.pool
import requests import requests
from dotenv import load_dotenv 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 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("/") @app.get("/")
def home() -> Response: def home() -> Response:
return send_from_directory(ROOT_DIR, "index.html") 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) 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") @app.get("/healthz")
def healthz() -> Response: def healthz() -> Response:
try: try:

View File

@@ -516,8 +516,27 @@
const favTag = link.favorite ? `<span class="tag fav">★ 즐겨찾기</span>` : ""; const favTag = link.favorite ? `<span class="tag fav">★ 즐겨찾기</span>` : "";
const lockTag = accessible ? "" : `<span class="tag lock">접근 제한</span>`; const lockTag = accessible ? "" : `<span class="tag lock">접근 제한</span>`;
const letter = esc((link.title || d || "L").trim().slice(0, 1).toUpperCase()); 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 openHtml = accessible
? `<a class="btn mini" href="${u}" target="_blank" rel="noopener noreferrer">열기</a>` ? `<a class="btn mini" href="${openHref}" target="_blank" rel="noopener noreferrer">열기</a>`
: `<button class="btn mini" type="button" disabled aria-disabled="true" title="이 링크는 현재 권한으로 접근할 수 없습니다.">열기</button>`; : `<button class="btn mini" type="button" disabled aria-disabled="true" title="이 링크는 현재 권한으로 접근할 수 없습니다.">열기</button>`;
const copyAttrs = accessible ? "" : ` disabled aria-disabled="true" title="이 링크는 현재 권한으로 접근할 수 없습니다."`; const copyAttrs = accessible ? "" : ` disabled aria-disabled="true" title="이 링크는 현재 권한으로 접근할 수 없습니다."`;
return ` return `

View File

@@ -98,6 +98,25 @@
return ACCESS_ANON_IDS.has(id); 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 = { const auth = {
client: null, client: null,
user: null, user: null,
@@ -308,8 +327,9 @@
const accessDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\""; const accessDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\"";
const accessDisabledTitle = accessible ? "" : " title=\"이 링크는 현재 권한으로 접근할 수 없습니다.\""; const accessDisabledTitle = accessible ? "" : " title=\"이 링크는 현재 권한으로 접근할 수 없습니다.\"";
const openHref = escapeHtml(buildOpenUrl(link.url));
const openHtml = accessible const openHtml = accessible
? `<a class="btn mini" href="${url}" target="_blank" rel="noopener noreferrer" data-act="open">열기</a>` ? `<a class="btn mini" href="${openHref}" target="_blank" rel="noopener noreferrer" data-act="open">열기</a>`
: `<button class="btn mini" type="button"${accessDisabledAttr}${accessDisabledTitle}>열기</button>`; : `<button class="btn mini" type="button"${accessDisabledAttr}${accessDisabledTitle}>열기</button>`;
const copyDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\""; const copyDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\"";