Compare commits

...

10 Commits

Author SHA1 Message Date
www-data
38b887aa0b init 2026-02-09 19:58:23 +09:00
dsyoon
cfa8c98872 링크 공유 저장(links.json) 지원
- Flask에 /api/links 추가: GET은 links.json 로드, PUT은 관리자만 links.json 저장
- 프론트는 /api/links 사용 가능 시 serverMode로 전환하여 추가/편집/삭제/즐겨찾기/가져오기를 서버에 저장

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 19:55:40 +09:00
dsyoon
9074764273 Roundcube 스킨 파비콘 탐색 및 CSP 안전 폴백
- mail.ncue.net에서 Roundcube 스킨 경로 favicon 후보를 순차 탐색
- 이미지 오류 처리 로직을 JS 이벤트로 바꿔 CSP 환경에서도 폴백 동작

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 12:59:47 +09:00
dsyoon
6b426eaccc mail.ncue.net(Roundcube) 파비콘 보강
- mail.ncue.net은 /roundcube/favicon.ico를 우선 시도
- 실패 시 /favicon.ico로 자동 재시도 후 글자 폴백
- script.js 및 index.html 폴백에 동일 적용

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 12:55:00 +09:00
dsyoon
39629006a7 경로 기반 파비콘 지원
- ncue.net 및 하위 도메인에서 /{첫경로}/favicon.ico 우선 사용
- index.html 폴백 모드에서도 파비콘 이미지 렌더링 및 실패 시 글자 폴백

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 12:26:12 +09:00
dsyoon
a72dcb154a 열기 링크에 사용자 식별값 전달
- /go 리다이렉트 엔드포인트 추가: 로그인 시 email, 비로그인 시 IP를 쿼리에 부착
- ncue.net 및 하위 도메인 링크에만 적용(안전한 allowlist)
- script.js 및 index.html 폴백에서 열기 버튼을 /go 경유로 변경

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 11:55:20 +09:00
www-data
d0a6b066b0 init 2026-02-08 09:59:49 +09:00
www-data
909d3d0ef8 init 2026-02-08 09:59:15 +09:00
dsyoon
d161b61783 관리자 이메일 확장 및 폴백 동작 정리
- 기본 관리자 이메일 목록에 추가 계정 반영
- script.js 로드 실패 시 폴백에서도 /api/config/auth hydrate 및 /api/auth/sync 호출
- 폴백에서 관리자 전용 기능 잠금 및 로그인 전 내보내기 비활성화 적용

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 09:56:58 +09:00
dsyoon
62768602c2 README 운영 구조 반영
- Flask 엔드포인트/Apache 프록시/systemd env 우선순위 주의사항 정리
- Auth0 도메인/클라이언트 매칭 및 localStorage override 관련 트러블슈팅 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 09:42:52 +09:00
8 changed files with 597 additions and 45 deletions

View File

@@ -14,7 +14,7 @@ AUTH0_CLIENT_ID=g5RDfax7FZkKzXYvXOgku3Ll8CxuA4IM
# Google connection name (usually google-oauth2) # Google connection name (usually google-oauth2)
AUTH0_GOOGLE_CONNECTION=google-oauth2 AUTH0_GOOGLE_CONNECTION=google-oauth2
# Admin emails (comma-separated) # Admin emails (comma-separated)
ADMIN_EMAILS=dosangyoon@gmail.com,dsyoon@ncue.net ADMIN_EMAILS=dsyoon@ncue.net,dosangyoon@gmail.com,dosangyoon2@gmail.com,dosangyoon3@gmail.com
## Optional ## Optional
# Server port # Server port

View File

@@ -23,6 +23,13 @@ python3 -m http.server 8000
현재 백엔드는 **Python Flask(기본 포트 8023)** 로 제공합니다. (정적 HTML/JS는 그대로 사용 가능) 현재 백엔드는 **Python Flask(기본 포트 8023)** 로 제공합니다. (정적 HTML/JS는 그대로 사용 가능)
### 핵심 엔드포인트
- `GET /healthz`: DB 연결 헬스체크
- `GET /api/config/auth`: Auth0 설정 조회(우선순위: `.env` → DB `ncue_app_config`)
- `POST /api/auth/sync`: 로그인 시 `ncue_user` upsert(최초/마지막 로그인 시각, `can_manage` 갱신)
- `POST /api/auth/logout`: 로그아웃 시각 기록
### 1) DB 테이블 생성(서버에서 1회) ### 1) DB 테이블 생성(서버에서 1회)
```bash ```bash
@@ -84,23 +91,21 @@ ExecStart=/bin/bash -lc 'source /opt/miniconda3/etc/profile.d/conda.sh && conda
Restart=always Restart=always
RestartSec=3 RestartSec=3
# .env 대신 systemd Environment로 주입(권장) # (권장) .env 파일을 systemd가 읽도록 설정
Environment=DB_HOST=ncue.net EnvironmentFile=/path/to/home/.env
Environment=DB_PORT=5432
Environment=DB_NAME=ncue # (선택) DB 연결 옵션(문제 발생 시 조정)
Environment=DB_USER=ncue Environment=DB_SSLMODE=prefer
Environment=DB_PASSWORD=REPLACE_ME Environment=DB_CONNECT_TIMEOUT=5
Environment=TABLE=ncue_user
Environment=AUTH0_DOMAIN=ncue.net
Environment=AUTH0_CLIENT_ID=g5RDfax7FZkKzXYvXOgku3Ll8CxuA4IM
Environment=AUTH0_GOOGLE_CONNECTION=google-oauth2
Environment=ADMIN_EMAILS=dosangyoon@gmail.com,dsyoon@ncue.net
Environment=PORT=8023
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
``` ```
> 중요:
> - `Environment=`는 반드시 `[Service]` 섹션에 있어야 합니다. `[Install]` 아래에 두면 무시됩니다.
> - `Environment=`로 Auth0/DB 값을 적어두면 `.env`보다 우선할 수 있으니, 운영에서는 **한 군데(.env)** 로 통일하는 것을 권장합니다.
적용/재시작: 적용/재시작:
```bash ```bash
@@ -206,8 +211,28 @@ update ncue_user set can_manage = true where email = 'me@example.com';
- `AUTH0_GOOGLE_CONNECTION` (예: `google-oauth2`) - `AUTH0_GOOGLE_CONNECTION` (예: `google-oauth2`)
- `ADMIN_EMAILS` (예: `dosangyoon@gmail.com,dsyoon@ncue.net`) - `ADMIN_EMAILS` (예: `dosangyoon@gmail.com,dsyoon@ncue.net`)
3. Auth0 Application 설정에서 아래 URL들을 등록 3. Auth0 Application 설정에서 아래 URL들을 등록
- Allowed Callback URLs: 사이트 주소 (예: `https://example.com/`) - Allowed Callback URLs: `https://ncue.net/``https://ncue.net`
- Allowed Logout URLs: 사이트 주소 (예: `https://example.com/`) - Allowed Logout URLs: `https://ncue.net/``https://ncue.net`
- Allowed Web Origins: `https://ncue.net`
- Allowed Origins (CORS): `https://ncue.net`
4. Applications → (해당 앱) → Connections 탭에서 `google-oauth2`를 Enable(ON)
### 자주 발생하는 오류
- `https://ncue.net/authorize ... Not Found`
- 원인: `AUTH0_DOMAIN`을 사이트 도메인(`ncue.net`)로 넣은 경우. Auth0 테넌트 도메인(예: `dev-xxxx.us.auth0.com`)을 넣어야 합니다.
- `invalid_request: Unknown client: ...`
- 원인: `AUTH0_DOMAIN`(테넌트)와 `AUTH0_CLIENT_ID`(앱)가 서로 다른 Auth0 테넌트에 속해 매칭이 안 되는 경우.
- 값 변경 후에도 로그인 URL이 바뀌지 않음
- 원인: 브라우저 `localStorage`에 이전 설정 override가 남아있는 경우.
- 해결(콘솔에서 실행):
```js
localStorage.removeItem("links_home_auth_override_v1");
localStorage.removeItem("links_home_auth_tokens_v1");
sessionStorage.removeItem("links_home_auth_pkce_v1");
location.reload();
```
## 데이터 저장 ## 데이터 저장
@@ -215,7 +240,3 @@ update ncue_user set can_manage = true where email = 'me@example.com';
- 사용자가 추가/편집/삭제한 내용: 브라우저 `localStorage`에 저장됩니다. - 사용자가 추가/편집/삭제한 내용: 브라우저 `localStorage`에 저장됩니다.
- 내보내기: 현재 화면 기준 링크를 JSON으로 다운로드합니다. - 내보내기: 현재 화면 기준 링크를 JSON으로 다운로드합니다.
- 가져오기: 내보내기 JSON(배열 또는 `{links:[...]}`)을 다시 불러옵니다. - 가져오기: 내보내기 JSON(배열 또는 `{links:[...]}`)을 다시 불러옵니다.
## 설치 방

1
ads.txt Normal file
View File

@@ -0,0 +1 @@
google.com, pub-5000757765244758, DIRECT, f08c47fec0942fa0

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
@@ -168,6 +169,35 @@ def bearer_token() -> str:
return m.group(1).strip() if m else "" return m.group(1).strip() if m else ""
def verify_admin_from_request() -> Tuple[bool, str]:
"""
Returns (is_admin, email_lowercase).
Uses the same headers as /api/auth/sync:
- Authorization: Bearer <id_token>
- X-Auth0-Issuer
- X-Auth0-ClientId
"""
id_token = bearer_token()
if not id_token:
return (False, "")
issuer = str(request.headers.get("X-Auth0-Issuer", "")).strip()
audience = str(request.headers.get("X-Auth0-ClientId", "")).strip()
if not issuer or not audience:
return (False, "")
payload = verify_id_token(id_token, issuer=issuer, audience=audience)
email = (str(payload.get("email")).strip().lower() if payload.get("email") else "")
return (bool(email and is_admin_email(email)), email)
def safe_write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp.replace(path)
@dataclass(frozen=True) @dataclass(frozen=True)
class JwksCacheEntry: class JwksCacheEntry:
jwks_url: str jwks_url: str
@@ -231,6 +261,7 @@ def verify_id_token(id_token: str, issuer: str, audience: str) -> dict:
ROOT_DIR = Path(__file__).resolve().parent ROOT_DIR = Path(__file__).resolve().parent
LINKS_FILE = ROOT_DIR / "links.json"
app = Flask(__name__) app = Flask(__name__)
@@ -248,6 +279,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 +323,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:
@@ -406,6 +497,49 @@ def api_config_auth_get() -> Response:
return jsonify({"ok": False, "error": "server_error"}), 500 return jsonify({"ok": False, "error": "server_error"}), 500
@app.get("/api/links")
def api_links_get() -> Response:
"""
Shared links source for all browsers.
Reads from links.json on disk (same directory).
"""
try:
if not LINKS_FILE.exists():
return jsonify({"ok": True, "links": []})
raw = LINKS_FILE.read_text(encoding="utf-8")
data = json.loads(raw) if raw.strip() else []
links = data if isinstance(data, list) else data.get("links") if isinstance(data, dict) else []
if not isinstance(links, list):
links = []
return jsonify({"ok": True, "links": links})
except Exception:
return jsonify({"ok": False, "error": "server_error"}), 500
@app.put("/api/links")
def api_links_put() -> Response:
"""
Admin-only: overwrite shared links.json with provided array.
Body can be:
- JSON array
- {"links":[...]}
"""
try:
ok_admin, _email = verify_admin_from_request()
if not ok_admin:
return jsonify({"ok": False, "error": "forbidden"}), 403
body = request.get_json(silent=True)
links = body if isinstance(body, list) else body.get("links") if isinstance(body, dict) else None
if not isinstance(links, list):
return jsonify({"ok": False, "error": "invalid_body"}), 400
safe_write_json(LINKS_FILE, links)
return jsonify({"ok": True, "count": len(links)})
except Exception:
return jsonify({"ok": False, "error": "server_error"}), 500
@app.post("/api/config/auth") @app.post("/api/config/auth")
def api_config_auth_post() -> Response: def api_config_auth_post() -> Response:
try: try:

View File

@@ -433,11 +433,15 @@
"link-ncue-net", "link-ncue-net",
"dreamgirl-ncue-net", "dreamgirl-ncue-net",
]); ]);
const ACCESS_ADMIN_EMAILS = new Set(["dosangyoon@gmail.com", "dsyoon@ncue.net"]);
let sessionEmail = ""; let sessionEmail = "";
function isAdminEmail(email) { function isAdminEmail(email) {
return ACCESS_ADMIN_EMAILS.has(String(email || "").trim().toLowerCase()); const cfg = getAuthConfig();
const admins = cfg && Array.isArray(cfg.adminEmails) ? cfg.adminEmails : [];
const e = String(email || "").trim().toLowerCase();
if (admins.length) return admins.includes(e);
// fallback default
return ["dsyoon@ncue.net", "dosangyoon@gmail.com", "dosangyoon2@gmail.com", "dosangyoon3@gmail.com"].includes(e);
} }
function canAccessLink(link) { function canAccessLink(link) {
const id = String(link && link.id ? link.id : ""); const id = String(link && link.id ? link.id : "");
@@ -512,15 +516,76 @@
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 faviconCandidates(rawUrl) {
try {
const uu = new URL(String(rawUrl || ""));
const host = String(uu.hostname || "").toLowerCase();
const isNcue = host === "ncue.net" || host.endsWith(".ncue.net");
const parts = uu.pathname.split("/").filter(Boolean);
const rootFav = `${uu.origin}/favicon.ico`;
const candidates = [];
if (host === "mail.ncue.net") {
candidates.push(
`${uu.origin}/roundcube/skins/elastic/images/favicon.ico`,
`${uu.origin}/roundcube/skins/larry/images/favicon.ico`,
`${uu.origin}/roundcube/skins/classic/images/favicon.ico`,
`${uu.origin}/skins/elastic/images/favicon.ico`,
`${uu.origin}/skins/larry/images/favicon.ico`,
`${uu.origin}/skins/classic/images/favicon.ico`,
`${uu.origin}/roundcube/favicon.ico`
);
}
const pathFav = isNcue && parts.length ? `${uu.origin}/${parts[0]}/favicon.ico` : "";
const list = [];
if (pathFav) list.push(pathFav);
list.push(...candidates);
list.push(rootFav);
const uniq = [];
const seen = new Set();
for (const x of list) {
const v = String(x || "").trim();
if (!v) continue;
if (seen.has(v)) continue;
seen.add(v);
uniq.push(v);
}
return { primary: uniq[0] || "", fallbackList: uniq.slice(1) };
} catch {
return { primary: "", fallbackList: [] };
}
}
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="이 링크는 현재 권한으로 접근할 수 없습니다."`;
const fav = faviconCandidates(link.url);
const faviconHtml = fav && fav.primary
? `<img src="${esc(fav.primary)}" data-fb="${esc((fav.fallbackList || []).join("|"))}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer" />`
: `<div class="letter">${letter}</div>`;
return ` return `
<article class="card${accessible ? "" : " disabled"}" data-id="${esc(link.id)}" data-access="${accessible ? "1" : "0"}"> <article class="card${accessible ? "" : " disabled"}" data-id="${esc(link.id)}" data-access="${accessible ? "1" : "0"}">
<div class="card-head"> <div class="card-head">
<div class="card-title"> <div class="card-title">
<div class="favicon" aria-hidden="true"><div class="letter">${letter}</div></div> <div class="favicon" aria-hidden="true" data-letter="${letter}">${faviconHtml}</div>
<div class="title-wrap"> <div class="title-wrap">
<div class="title" title="${t}">${t}</div> <div class="title" title="${t}">${t}</div>
<div class="domain" title="${d}">${d}</div> <div class="domain" title="${d}">${d}</div>
@@ -550,6 +615,32 @@
.filter((l) => matches(l, q)) .filter((l) => matches(l, q))
.sort(compare); .sort(compare);
if (el.grid) el.grid.innerHTML = filtered.map(cardHtml).join(""); if (el.grid) el.grid.innerHTML = filtered.map(cardHtml).join("");
// bind favicon fallback chain (no inline onerror; works under CSP)
if (el.grid) {
const imgs = el.grid.querySelectorAll("img[data-fb]");
for (const img of imgs) {
if (img.dataset.bound === "1") continue;
img.dataset.bound = "1";
img.addEventListener(
"error",
() => {
const fb = String(img.dataset.fb || "");
const list = fb ? fb.split("|").filter(Boolean) : [];
const next = list.shift();
if (next) {
img.dataset.fb = list.join("|");
img.src = next;
return;
}
const p = img.parentNode;
const letter = p && p.getAttribute ? p.getAttribute("data-letter") : "";
img.remove();
if (p) p.insertAdjacentHTML("beforeend", `<div class="letter">${esc(letter || "L")}</div>`);
},
{ passive: true }
);
}
}
if (el.empty) el.empty.hidden = filtered.length !== 0; if (el.empty) el.empty.hidden = filtered.length !== 0;
if (el.meta) { if (el.meta) {
const favCount = all.filter((l) => l.favorite).length; const favCount = all.filter((l) => l.favorite).length;
@@ -663,6 +754,50 @@
ready: false, ready: false,
}; };
function apiUrl(pathname) {
// same-origin only in fallback
return pathname;
}
async function hydrateAuthConfigFromServerIfNeeded() {
const cfg = getAuthConfig();
const hasLocal = Boolean(cfg.auth0.domain && cfg.auth0.clientId && cfg.connections.google);
if (hasLocal) return true;
try {
const r = await fetch(apiUrl("/api/config/auth"), { cache: "no-store" });
if (!r.ok) return false;
const data = await r.json();
if (!data || !data.ok || !data.value) return false;
const v = data.value;
const auth0 = v.auth0 || {};
const connections = v.connections || {};
const adminEmails = Array.isArray(v.adminEmails) ? v.adminEmails : Array.isArray(v.allowedEmails) ? v.allowedEmails : [];
const domain = String(auth0.domain || "").trim();
const clientId = String(auth0.clientId || "").trim();
const google = String(connections.google || "").trim();
if (!domain || !clientId || !google) return false;
localStorage.setItem(
AUTH_OVERRIDE_KEY,
JSON.stringify({
auth0: { domain, clientId },
connections: { google },
adminEmails,
})
);
return true;
} catch {
return false;
}
}
function applyManageLock() {
const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail);
if (el.btnAdd) el.btnAdd.disabled = !canManage;
if (el.btnImport) el.btnImport.disabled = !canManage;
// 요청: 로그인 전 내보내기 비활성화
if (el.btnExport) el.btnExport.disabled = !auth.user;
}
function loadAuthOverride() { function loadAuthOverride() {
const raw = localStorage.getItem(AUTH_OVERRIDE_KEY); const raw = localStorage.getItem(AUTH_OVERRIDE_KEY);
const data = raw ? safeJsonParse(raw, null) : null; const data = raw ? safeJsonParse(raw, null) : null;
@@ -736,6 +871,7 @@
async function ensureAuthClient() { async function ensureAuthClient() {
if (auth.client) return auth.client; if (auth.client) return auth.client;
await hydrateAuthConfigFromServerIfNeeded();
const cfg = getAuthConfig(); const cfg = getAuthConfig();
if (!cfg.auth0.domain || !cfg.auth0.clientId) return null; if (!cfg.auth0.domain || !cfg.auth0.clientId) return null;
if (typeof window.createAuth0Client !== "function") return null; if (typeof window.createAuth0Client !== "function") return null;
@@ -756,6 +892,7 @@
const client = await ensureAuthClient(); const client = await ensureAuthClient();
if (!client) { if (!client) {
// No config: keep buttons visible but disabled style // No config: keep buttons visible but disabled style
applyManageLock();
return; return;
} }
const u = new URL(location.href); const u = new URL(location.href);
@@ -774,6 +911,29 @@
if (el.snsLogin) el.snsLogin.hidden = Boolean(auth.user); if (el.snsLogin) el.snsLogin.hidden = Boolean(auth.user);
if (el.user) el.user.hidden = !auth.user; if (el.user) el.user.hidden = !auth.user;
if (el.userText && auth.user) el.userText.textContent = auth.user.email || auth.user.name || "로그인됨"; if (el.userText && auth.user) el.userText.textContent = auth.user.email || auth.user.name || "로그인됨";
// sync user to server (upsert ncue_user)
if (auth.user) {
try {
const claims = await client.getIdTokenClaims();
const raw = claims && claims.__raw ? String(claims.__raw) : "";
if (raw) {
const cfg = getAuthConfig();
await fetch(apiUrl("/api/auth/sync"), {
method: "POST",
headers: {
Authorization: `Bearer ${raw}`,
"X-Auth0-Issuer": `https://${cfg.auth0.domain}/`,
"X-Auth0-ClientId": cfg.auth0.clientId,
},
}).catch(() => {});
}
} catch {
// ignore
}
}
applyManageLock();
render(); render();
} }
@@ -876,6 +1036,13 @@
e.preventDefault(); e.preventDefault();
return; return;
} }
// block manage actions unless admin
const act0 = btn.getAttribute("data-act");
const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail);
if (!canManage && (act0 === "fav" || act0 === "edit" || act0 === "del")) {
toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다.");
return;
}
const act = btn.getAttribute("data-act"); const act = btn.getAttribute("data-act");
const link = id ? getById(id) : null; const link = id ? getById(id) : null;
if (!link) return; if (!link) return;
@@ -899,6 +1066,8 @@
if (el.btnExport) el.btnExport.addEventListener("click", exportJson); if (el.btnExport) el.btnExport.addEventListener("click", exportJson);
if (el.btnImport) if (el.btnImport)
el.btnImport.addEventListener("click", () => { el.btnImport.addEventListener("click", () => {
const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail);
if (!canManage) return toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다.");
if (el.file) el.file.click(); if (el.file) el.file.click();
}); });
if (el.file) if (el.file)
@@ -926,6 +1095,7 @@
render(); render();
initAuth().catch(() => {}); initAuth().catch(() => {});
applyManageLock();
toast("스크립트 로딩 문제로 폴백 모드로 실행 중입니다."); toast("스크립트 로딩 문제로 폴백 모드로 실행 중입니다.");
}, 200); }, 200);
})(); })();

View File

@@ -4,7 +4,10 @@
"title": "DSYoon", "title": "DSYoon",
"url": "https://ncue.net/dsyoon", "url": "https://ncue.net/dsyoon",
"description": "개인 페이지", "description": "개인 페이지",
"tags": ["personal", "ncue"], "tags": [
"personal",
"ncue"
],
"favorite": false, "favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z", "createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z" "updatedAt": "2026-02-07T00:00:00.000Z"
@@ -14,7 +17,10 @@
"title": "Family", "title": "Family",
"url": "https://ncue.net/family", "url": "https://ncue.net/family",
"description": "Family", "description": "Family",
"tags": ["personal", "ncue"], "tags": [
"personal",
"ncue"
],
"favorite": false, "favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z", "createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z" "updatedAt": "2026-02-07T00:00:00.000Z"
@@ -24,7 +30,10 @@
"title": "Link", "title": "Link",
"url": "https://link.ncue.net/", "url": "https://link.ncue.net/",
"description": "NCUE 링크 허브", "description": "NCUE 링크 허브",
"tags": ["link", "ncue"], "tags": [
"link",
"ncue"
],
"favorite": false, "favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z", "createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z" "updatedAt": "2026-02-07T00:00:00.000Z"
@@ -34,7 +43,10 @@
"title": "DreamGirl", "title": "DreamGirl",
"url": "https://ncue.net/dreamgirl", "url": "https://ncue.net/dreamgirl",
"description": "DreamGirl", "description": "DreamGirl",
"tags": ["personal", "ncue"], "tags": [
"personal",
"ncue"
],
"favorite": false, "favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z", "createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z" "updatedAt": "2026-02-07T00:00:00.000Z"
@@ -44,7 +56,11 @@
"title": "TTS", "title": "TTS",
"url": "https://tts.ncue.net/", "url": "https://tts.ncue.net/",
"description": "입력한 text를 mp3로 변환", "description": "입력한 text를 mp3로 변환",
"tags": ["text", "mp3", "ncue"], "tags": [
"text",
"mp3",
"ncue"
],
"favorite": false, "favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z", "createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z" "updatedAt": "2026-02-07T00:00:00.000Z"
@@ -54,7 +70,10 @@
"title": "Meeting", "title": "Meeting",
"url": "https://meeting.ncue.net/", "url": "https://meeting.ncue.net/",
"description": "NCUE 미팅", "description": "NCUE 미팅",
"tags": ["meeting", "ncue"], "tags": [
"meeting",
"ncue"
],
"favorite": false, "favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z", "createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z" "updatedAt": "2026-02-07T00:00:00.000Z"
@@ -64,7 +83,10 @@
"title": "Git", "title": "Git",
"url": "https://git.ncue.net/", "url": "https://git.ncue.net/",
"description": "NCUE Git 서비스", "description": "NCUE Git 서비스",
"tags": ["dev", "ncue"], "tags": [
"dev",
"ncue"
],
"favorite": false, "favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z", "createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z" "updatedAt": "2026-02-07T00:00:00.000Z"
@@ -74,7 +96,10 @@
"title": "Mail", "title": "Mail",
"url": "https://mail.ncue.net/", "url": "https://mail.ncue.net/",
"description": "NCUE 메일", "description": "NCUE 메일",
"tags": ["mail", "ncue"], "tags": [
"mail",
"ncue"
],
"favorite": false, "favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z", "createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z" "updatedAt": "2026-02-07T00:00:00.000Z"
@@ -84,9 +109,25 @@
"title": "OpenClaw", "title": "OpenClaw",
"url": "https://openclaw.ncue.net/", "url": "https://openclaw.ncue.net/",
"description": "OpenClaw", "description": "OpenClaw",
"tags": ["tool", "ncue"], "tags": [
"tool",
"ncue"
],
"favorite": false, "favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z", "createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z" "updatedAt": "2026-02-07T00:00:00.000Z"
},
{
"id": "custom-bb11a707-5e7b-4612-b0b1-6867398bbf99",
"title": "STT",
"url": "https://ncue.net/stt",
"description": "STT",
"tags": [
"STT",
"전사"
],
"favorite": false,
"createdAt": "2026-02-09T10:57:40.571Z",
"updatedAt": "2026-02-09T10:57:40.571Z"
} }
] ]

3
run.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
systemctl $1 ncue-flask

206
script.js
View File

@@ -58,6 +58,7 @@
sortKey: "json", sortKey: "json",
onlyFav: false, onlyFav: false,
canManage: false, canManage: false,
serverMode: false, // true when /api/links is available
}; };
// Access levels (open/copy) // Access levels (open/copy)
@@ -70,7 +71,12 @@
"link-ncue-net", "link-ncue-net",
"dreamgirl-ncue-net", "dreamgirl-ncue-net",
]); ]);
const DEFAULT_ADMIN_EMAILS = new Set(["dosangyoon@gmail.com", "dsyoon@ncue.net"]); const DEFAULT_ADMIN_EMAILS = new Set([
"dsyoon@ncue.net",
"dosangyoon@gmail.com",
"dosangyoon2@gmail.com",
"dosangyoon3@gmail.com",
]);
function getUserEmail() { function getUserEmail() {
const e = auth && auth.user && auth.user.email ? String(auth.user.email) : ""; const e = auth && auth.user && auth.user.email ? String(auth.user.email) : "";
@@ -93,6 +99,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,
@@ -100,6 +125,7 @@
ready: false, ready: false,
mode: "disabled", // enabled | misconfigured | sdk_missing | disabled mode: "disabled", // enabled | misconfigured | sdk_missing | disabled
serverCanManage: null, serverCanManage: null,
idTokenRaw: "",
}; };
function nowIso() { function nowIso() {
@@ -157,12 +183,83 @@
} }
} }
function faviconUrl(url) { function faviconCandidates(url) {
try { try {
const u = new URL(url); const u = new URL(url);
return `${u.origin}/favicon.ico`; const host = String(u.hostname || "").toLowerCase();
const isNcue = host === "ncue.net" || host.endsWith(".ncue.net");
const parts = u.pathname.split("/").filter(Boolean);
const rootFav = `${u.origin}/favicon.ico`;
// Host-specific hints (Roundcube etc.)
const candidates = [];
if (host === "mail.ncue.net") {
// common Roundcube skin favicon locations (server files)
candidates.push(
`${u.origin}/roundcube/skins/elastic/images/favicon.ico`,
`${u.origin}/roundcube/skins/larry/images/favicon.ico`,
`${u.origin}/roundcube/skins/classic/images/favicon.ico`,
// sometimes Roundcube is mounted at /
`${u.origin}/skins/elastic/images/favicon.ico`,
`${u.origin}/skins/larry/images/favicon.ico`,
`${u.origin}/skins/classic/images/favicon.ico`,
// legacy attempt
`${u.origin}/roundcube/favicon.ico`
);
}
// Path-based favicon like https://ncue.net/dsyoon/favicon.ico (internal only)
const pathFav = isNcue && parts.length ? `${u.origin}/${parts[0]}/favicon.ico` : "";
const list = [];
if (pathFav) list.push(pathFav);
list.push(...candidates);
list.push(rootFav);
// de-dup + drop empties
const uniq = [];
const seen = new Set();
for (const x of list) {
const v = String(x || "").trim();
if (!v) continue;
if (seen.has(v)) continue;
seen.add(v);
uniq.push(v);
}
const primary = uniq[0] || "";
const rest = uniq.slice(1);
return { primary, fallbackList: rest };
} catch { } catch {
return ""; return { primary: "", fallbackList: [] };
}
}
function wireFaviconFallbacks() {
const imgs = el.grid ? el.grid.querySelectorAll("img[data-fb]") : [];
for (const img of imgs) {
if (img.dataset.bound === "1") continue;
img.dataset.bound = "1";
img.addEventListener(
"error",
() => {
const fb = String(img.dataset.fb || "");
const list = fb ? fb.split("|").filter(Boolean) : [];
const next = list.shift();
if (next) {
img.dataset.fb = list.join("|");
img.src = next;
return;
}
const p = img.parentNode;
const letter = p && p.getAttribute ? p.getAttribute("data-letter") : "";
img.remove();
if (p) {
p.insertAdjacentHTML("beforeend", `<div class="letter">${escapeHtml(letter || "L")}</div>`);
}
},
{ passive: true }
);
} }
} }
@@ -199,6 +296,10 @@
} }
function getMergedLinks() { function getMergedLinks() {
if (state.serverMode) {
// In serverMode, baseLinks is the shared source of truth.
return (state.baseLinks || []).map(normalizeLink);
}
const tomb = new Set(state.store.tombstones || []); const tomb = new Set(state.store.tombstones || []);
const overrides = state.store.overridesById || {}; const overrides = state.store.overridesById || {};
@@ -272,6 +373,7 @@
.sort(compareLinks); .sort(compareLinks);
el.grid.innerHTML = filtered.map(cardHtml).join(""); el.grid.innerHTML = filtered.map(cardHtml).join("");
wireFaviconFallbacks();
el.empty.hidden = filtered.length !== 0; el.empty.hidden = filtered.length !== 0;
const favCount = all.filter((l) => l.favorite).length; const favCount = all.filter((l) => l.favorite).length;
@@ -298,13 +400,14 @@
const letter = escapeHtml((link.title || domain || "L").trim().slice(0, 1).toUpperCase()); const letter = escapeHtml((link.title || domain || "L").trim().slice(0, 1).toUpperCase());
const lockAttr = state.canManage ? "" : ' disabled aria-disabled="true"'; const lockAttr = state.canManage ? "" : ' disabled aria-disabled="true"';
const lockTitle = state.canManage ? "" : ' title="관리 기능은 로그인 후 사용 가능합니다."'; const lockTitle = state.canManage ? "" : ' title="관리 기능은 로그인 후 사용 가능합니다."';
const fav = faviconUrl(link.url); const fav = faviconCandidates(link.url);
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\"";
@@ -316,10 +419,10 @@
}"> }">
<div class="card-head"> <div class="card-head">
<div class="card-title"> <div class="card-title">
<div class="favicon" aria-hidden="true"> <div class="favicon" aria-hidden="true" data-letter="${letter}">
${ ${
fav fav && fav.primary
? `<img src="${escapeHtml(fav)}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer" onerror="const p=this.parentNode; this.remove(); if(p) p.insertAdjacentHTML('beforeend','<div class=&quot;letter&quot;>${letter}</div>');" />` ? `<img src="${escapeHtml(fav.primary)}" data-fb="${escapeHtml((fav.fallbackList || []).join("|"))}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer" />`
: `<div class="letter">${letter}</div>` : `<div class="letter">${letter}</div>`
} }
</div> </div>
@@ -696,6 +799,7 @@
async function syncUserToServerWithIdToken(idToken) { async function syncUserToServerWithIdToken(idToken) {
try { try {
auth.idTokenRaw = String(idToken || "");
const cfg = getAuthConfig(); const cfg = getAuthConfig();
const r = await fetch(apiUrl("/api/auth/sync"), { const r = await fetch(apiUrl("/api/auth/sync"), {
method: "POST", method: "POST",
@@ -721,6 +825,47 @@
} }
} }
async function saveLinksToServer(links) {
if (!auth.idTokenRaw) return false;
try {
const cfg = getAuthConfig();
const r = await fetch(apiUrl("/api/links"), {
method: "PUT",
headers: {
Authorization: `Bearer ${auth.idTokenRaw}`,
"X-Auth0-Issuer": `https://${cfg.auth0.domain}/`,
"X-Auth0-ClientId": cfg.auth0.clientId,
"Content-Type": "application/json",
},
body: JSON.stringify(Array.isArray(links) ? links : []),
});
return r.ok;
} catch {
return false;
}
}
async function loadLinksFromServer() {
try {
const r = await fetch(apiUrl("/api/links"), { cache: "no-store" });
if (!r.ok) return null;
const data = await r.json();
const list = data && Array.isArray(data.links) ? data.links : null;
if (!list) return null;
return list.map(normalizeLink);
} catch {
return null;
}
}
async function persistLinksIfServerMode() {
if (!state.serverMode) return;
if (!state.canManage) return;
const links = getMergedLinks(); // in serverMode this is baseLinks
const ok = await saveLinksToServer(links);
if (!ok) toastOnce("savefail", "서버 저장(links.json)에 실패했습니다. 권한/서버 로그를 확인하세요.");
}
function sendLogoutToServer(idToken) { function sendLogoutToServer(idToken) {
if (!idToken) return; if (!idToken) return;
const cfg = getAuthConfig(); const cfg = getAuthConfig();
@@ -842,6 +987,7 @@
auth.serverCanManage = null; auth.serverCanManage = null;
const t = loadTokens(); const t = loadTokens();
if (auth.user && t && t.id_token) { if (auth.user && t && t.id_token) {
auth.idTokenRaw = String(t.id_token || "");
const can = await syncUserToServerWithIdToken(t.id_token); const can = await syncUserToServerWithIdToken(t.id_token);
if (typeof can === "boolean") { if (typeof can === "boolean") {
auth.serverCanManage = can; auth.serverCanManage = can;
@@ -901,6 +1047,7 @@
const claims = await auth.client.getIdTokenClaims(); const claims = await auth.client.getIdTokenClaims();
const raw = claims && claims.__raw ? String(claims.__raw) : ""; const raw = claims && claims.__raw ? String(claims.__raw) : "";
if (raw) { if (raw) {
auth.idTokenRaw = raw;
const cfg = getAuthConfig(); const cfg = getAuthConfig();
const r = await fetch(apiUrl("/api/auth/sync"), { const r = await fetch(apiUrl("/api/auth/sync"), {
method: "POST", method: "POST",
@@ -1010,6 +1157,14 @@
} }
function upsertCustom(link) { function upsertCustom(link) {
if (state.serverMode) {
const n = normalizeLink(link);
const idx = state.baseLinks.findIndex((c) => c && c.id === n.id);
if (idx >= 0) state.baseLinks[idx] = n;
else state.baseLinks.push(n);
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
return;
}
const n = normalizeLink(link); const n = normalizeLink(link);
const idx = state.store.custom.findIndex((c) => c && c.id === n.id); const idx = state.store.custom.findIndex((c) => c && c.id === n.id);
if (idx >= 0) state.store.custom[idx] = n; if (idx >= 0) state.store.custom[idx] = n;
@@ -1018,6 +1173,13 @@
} }
function deleteLink(id) { function deleteLink(id) {
if (state.serverMode) {
const before = state.baseLinks.length;
state.baseLinks = (state.baseLinks || []).filter((l) => l && l.id !== id);
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
if (state.baseLinks.length !== before) toast("삭제했습니다.");
return;
}
if (isBaseId(id)) { if (isBaseId(id)) {
const s = new Set(state.store.tombstones || []); const s = new Set(state.store.tombstones || []);
s.add(id); s.add(id);
@@ -1037,6 +1199,11 @@
const link = getLinkById(id); const link = getLinkById(id);
if (!link) return; if (!link) return;
const next = !link.favorite; const next = !link.favorite;
if (state.serverMode) {
upsertCustom({ ...link, favorite: next, updatedAt: nowIso() });
render();
return;
}
if (isBaseId(id)) { if (isBaseId(id)) {
setOverride(id, { favorite: next, updatedAt: nowIso() }); setOverride(id, { favorite: next, updatedAt: nowIso() });
} else { } else {
@@ -1134,10 +1301,12 @@
} }
used.add(n.id); used.add(n.id);
// 가져오기는 custom로 추가(기본과 충돌 방지) // 가져오기는 custom로 추가(기본과 충돌 방지)
state.store.custom.push(n); if (state.serverMode) state.baseLinks.push(n);
else state.store.custom.push(n);
added++; added++;
} }
saveStore(); if (!state.serverMode) saveStore();
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
toast(`가져오기 완료: ${added}`); toast(`가져오기 완료: ${added}`);
} }
@@ -1162,6 +1331,7 @@
} }
if (act === "fav") { if (act === "fav") {
toggleFavorite(id); toggleFavorite(id);
persistLinksIfServerMode();
return; return;
} }
if (act === "copy") { if (act === "copy") {
@@ -1179,6 +1349,7 @@
if (confirm(`삭제할까요?\n\n- ${name}`)) { if (confirm(`삭제할까요?\n\n- ${name}`)) {
deleteLink(id); deleteLink(id);
render(); render();
persistLinksIfServerMode();
} }
return; return;
} }
@@ -1226,6 +1397,7 @@
closeModal(); closeModal();
render(); render();
toast("저장했습니다."); toast("저장했습니다.");
persistLinksIfServerMode();
return; return;
} }
@@ -1243,6 +1415,7 @@
closeModal(); closeModal();
render(); render();
toast("추가했습니다."); toast("추가했습니다.");
persistLinksIfServerMode();
} }
function wire() { function wire() {
@@ -1318,7 +1491,16 @@
wire(); wire();
await hydrateAuthConfigFromServerIfNeeded(); await hydrateAuthConfigFromServerIfNeeded();
await initAuth(); await initAuth();
state.baseLinks = await loadBaseLinks(); const serverLinks = await loadLinksFromServer();
if (serverLinks) {
state.serverMode = true;
state.baseLinks = serverLinks;
// serverMode에서는 localStorage 기반 커스텀/오버라이드는 사용하지 않음(공유 JSON이 진실)
state.store = { overridesById: {}, tombstones: [], custom: [] };
saveStore();
} else {
state.baseLinks = await loadBaseLinks();
}
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i])); state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
el.sort.value = state.sortKey; el.sort.value = state.sortKey;
applyManageLock(); applyManageLock();