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)
AUTH0_GOOGLE_CONNECTION=google-oauth2
# 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
# Server port

View File

@@ -23,6 +23,13 @@ python3 -m http.server 8000
현재 백엔드는 **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회)
```bash
@@ -84,23 +91,21 @@ ExecStart=/bin/bash -lc 'source /opt/miniconda3/etc/profile.d/conda.sh && conda
Restart=always
RestartSec=3
# .env 대신 systemd Environment로 주입(권장)
Environment=DB_HOST=ncue.net
Environment=DB_PORT=5432
Environment=DB_NAME=ncue
Environment=DB_USER=ncue
Environment=DB_PASSWORD=REPLACE_ME
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
# (권장) .env 파일을 systemd가 읽도록 설정
EnvironmentFile=/path/to/home/.env
# (선택) DB 연결 옵션(문제 발생 시 조정)
Environment=DB_SSLMODE=prefer
Environment=DB_CONNECT_TIMEOUT=5
[Install]
WantedBy=multi-user.target
```
> 중요:
> - `Environment=`는 반드시 `[Service]` 섹션에 있어야 합니다. `[Install]` 아래에 두면 무시됩니다.
> - `Environment=`로 Auth0/DB 값을 적어두면 `.env`보다 우선할 수 있으니, 운영에서는 **한 군데(.env)** 로 통일하는 것을 권장합니다.
적용/재시작:
```bash
@@ -206,8 +211,28 @@ update ncue_user set can_manage = true where email = 'me@example.com';
- `AUTH0_GOOGLE_CONNECTION` (예: `google-oauth2`)
- `ADMIN_EMAILS` (예: `dosangyoon@gmail.com,dsyoon@ncue.net`)
3. Auth0 Application 설정에서 아래 URL들을 등록
- Allowed Callback URLs: 사이트 주소 (예: `https://example.com/`)
- Allowed Logout URLs: 사이트 주소 (예: `https://example.com/`)
- Allowed Callback URLs: `https://ncue.net/``https://ncue.net`
- 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`에 저장됩니다.
- 내보내기: 현재 화면 기준 링크를 JSON으로 다운로드합니다.
- 가져오기: 내보내기 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 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
@@ -168,6 +169,35 @@ def bearer_token() -> str:
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)
class JwksCacheEntry:
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
LINKS_FILE = ROOT_DIR / "links.json"
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("/")
def home() -> Response:
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)
@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:
@@ -406,6 +497,49 @@ def api_config_auth_get() -> Response:
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")
def api_config_auth_post() -> Response:
try:

View File

@@ -433,11 +433,15 @@
"link-ncue-net",
"dreamgirl-ncue-net",
]);
const ACCESS_ADMIN_EMAILS = new Set(["dosangyoon@gmail.com", "dsyoon@ncue.net"]);
let sessionEmail = "";
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) {
const id = String(link && link.id ? link.id : "");
@@ -512,15 +516,76 @@
const favTag = link.favorite ? `<span class="tag fav">★ 즐겨찾기</span>` : "";
const lockTag = accessible ? "" : `<span class="tag lock">접근 제한</span>`;
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
? `<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>`;
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 `
<article class="card${accessible ? "" : " disabled"}" data-id="${esc(link.id)}" data-access="${accessible ? "1" : "0"}">
<div class="card-head">
<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" title="${t}">${t}</div>
<div class="domain" title="${d}">${d}</div>
@@ -550,6 +615,32 @@
.filter((l) => matches(l, q))
.sort(compare);
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.meta) {
const favCount = all.filter((l) => l.favorite).length;
@@ -663,6 +754,50 @@
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() {
const raw = localStorage.getItem(AUTH_OVERRIDE_KEY);
const data = raw ? safeJsonParse(raw, null) : null;
@@ -736,6 +871,7 @@
async function ensureAuthClient() {
if (auth.client) return auth.client;
await hydrateAuthConfigFromServerIfNeeded();
const cfg = getAuthConfig();
if (!cfg.auth0.domain || !cfg.auth0.clientId) return null;
if (typeof window.createAuth0Client !== "function") return null;
@@ -756,6 +892,7 @@
const client = await ensureAuthClient();
if (!client) {
// No config: keep buttons visible but disabled style
applyManageLock();
return;
}
const u = new URL(location.href);
@@ -774,6 +911,29 @@
if (el.snsLogin) el.snsLogin.hidden = Boolean(auth.user);
if (el.user) el.user.hidden = !auth.user;
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();
}
@@ -876,6 +1036,13 @@
e.preventDefault();
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 link = id ? getById(id) : null;
if (!link) return;
@@ -899,6 +1066,8 @@
if (el.btnExport) el.btnExport.addEventListener("click", exportJson);
if (el.btnImport)
el.btnImport.addEventListener("click", () => {
const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail);
if (!canManage) return toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다.");
if (el.file) el.file.click();
});
if (el.file)
@@ -926,6 +1095,7 @@
render();
initAuth().catch(() => {});
applyManageLock();
toast("스크립트 로딩 문제로 폴백 모드로 실행 중입니다.");
}, 200);
})();

View File

@@ -4,7 +4,10 @@
"title": "DSYoon",
"url": "https://ncue.net/dsyoon",
"description": "개인 페이지",
"tags": ["personal", "ncue"],
"tags": [
"personal",
"ncue"
],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
@@ -14,7 +17,10 @@
"title": "Family",
"url": "https://ncue.net/family",
"description": "Family",
"tags": ["personal", "ncue"],
"tags": [
"personal",
"ncue"
],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
@@ -24,7 +30,10 @@
"title": "Link",
"url": "https://link.ncue.net/",
"description": "NCUE 링크 허브",
"tags": ["link", "ncue"],
"tags": [
"link",
"ncue"
],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
@@ -34,7 +43,10 @@
"title": "DreamGirl",
"url": "https://ncue.net/dreamgirl",
"description": "DreamGirl",
"tags": ["personal", "ncue"],
"tags": [
"personal",
"ncue"
],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
@@ -44,7 +56,11 @@
"title": "TTS",
"url": "https://tts.ncue.net/",
"description": "입력한 text를 mp3로 변환",
"tags": ["text", "mp3", "ncue"],
"tags": [
"text",
"mp3",
"ncue"
],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
@@ -54,7 +70,10 @@
"title": "Meeting",
"url": "https://meeting.ncue.net/",
"description": "NCUE 미팅",
"tags": ["meeting", "ncue"],
"tags": [
"meeting",
"ncue"
],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
@@ -64,7 +83,10 @@
"title": "Git",
"url": "https://git.ncue.net/",
"description": "NCUE Git 서비스",
"tags": ["dev", "ncue"],
"tags": [
"dev",
"ncue"
],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
@@ -74,7 +96,10 @@
"title": "Mail",
"url": "https://mail.ncue.net/",
"description": "NCUE 메일",
"tags": ["mail", "ncue"],
"tags": [
"mail",
"ncue"
],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
@@ -84,9 +109,25 @@
"title": "OpenClaw",
"url": "https://openclaw.ncue.net/",
"description": "OpenClaw",
"tags": ["tool", "ncue"],
"tags": [
"tool",
"ncue"
],
"favorite": false,
"createdAt": "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

204
script.js
View File

@@ -58,6 +58,7 @@
sortKey: "json",
onlyFav: false,
canManage: false,
serverMode: false, // true when /api/links is available
};
// Access levels (open/copy)
@@ -70,7 +71,12 @@
"link-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() {
const e = auth && auth.user && auth.user.email ? String(auth.user.email) : "";
@@ -93,6 +99,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,
@@ -100,6 +125,7 @@
ready: false,
mode: "disabled", // enabled | misconfigured | sdk_missing | disabled
serverCanManage: null,
idTokenRaw: "",
};
function nowIso() {
@@ -157,12 +183,83 @@
}
}
function faviconUrl(url) {
function faviconCandidates(url) {
try {
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 {
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() {
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 overrides = state.store.overridesById || {};
@@ -272,6 +373,7 @@
.sort(compareLinks);
el.grid.innerHTML = filtered.map(cardHtml).join("");
wireFaviconFallbacks();
el.empty.hidden = filtered.length !== 0;
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 lockAttr = state.canManage ? "" : ' disabled aria-disabled="true"';
const lockTitle = state.canManage ? "" : ' title="관리 기능은 로그인 후 사용 가능합니다."';
const fav = faviconUrl(link.url);
const fav = faviconCandidates(link.url);
const accessDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\"";
const accessDisabledTitle = accessible ? "" : " title=\"이 링크는 현재 권한으로 접근할 수 없습니다.\"";
const openHref = escapeHtml(buildOpenUrl(link.url));
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>`;
const copyDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\"";
@@ -316,10 +419,10 @@
}">
<div class="card-head">
<div class="card-title">
<div class="favicon" aria-hidden="true">
<div class="favicon" aria-hidden="true" data-letter="${letter}">
${
fav
? `<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>');" />`
fav && fav.primary
? `<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>
@@ -696,6 +799,7 @@
async function syncUserToServerWithIdToken(idToken) {
try {
auth.idTokenRaw = String(idToken || "");
const cfg = getAuthConfig();
const r = await fetch(apiUrl("/api/auth/sync"), {
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) {
if (!idToken) return;
const cfg = getAuthConfig();
@@ -842,6 +987,7 @@
auth.serverCanManage = null;
const t = loadTokens();
if (auth.user && t && t.id_token) {
auth.idTokenRaw = String(t.id_token || "");
const can = await syncUserToServerWithIdToken(t.id_token);
if (typeof can === "boolean") {
auth.serverCanManage = can;
@@ -901,6 +1047,7 @@
const claims = await auth.client.getIdTokenClaims();
const raw = claims && claims.__raw ? String(claims.__raw) : "";
if (raw) {
auth.idTokenRaw = raw;
const cfg = getAuthConfig();
const r = await fetch(apiUrl("/api/auth/sync"), {
method: "POST",
@@ -1010,6 +1157,14 @@
}
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 idx = state.store.custom.findIndex((c) => c && c.id === n.id);
if (idx >= 0) state.store.custom[idx] = n;
@@ -1018,6 +1173,13 @@
}
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)) {
const s = new Set(state.store.tombstones || []);
s.add(id);
@@ -1037,6 +1199,11 @@
const link = getLinkById(id);
if (!link) return;
const next = !link.favorite;
if (state.serverMode) {
upsertCustom({ ...link, favorite: next, updatedAt: nowIso() });
render();
return;
}
if (isBaseId(id)) {
setOverride(id, { favorite: next, updatedAt: nowIso() });
} else {
@@ -1134,10 +1301,12 @@
}
used.add(n.id);
// 가져오기는 custom로 추가(기본과 충돌 방지)
state.store.custom.push(n);
if (state.serverMode) state.baseLinks.push(n);
else state.store.custom.push(n);
added++;
}
saveStore();
if (!state.serverMode) saveStore();
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
toast(`가져오기 완료: ${added}`);
}
@@ -1162,6 +1331,7 @@
}
if (act === "fav") {
toggleFavorite(id);
persistLinksIfServerMode();
return;
}
if (act === "copy") {
@@ -1179,6 +1349,7 @@
if (confirm(`삭제할까요?\n\n- ${name}`)) {
deleteLink(id);
render();
persistLinksIfServerMode();
}
return;
}
@@ -1226,6 +1397,7 @@
closeModal();
render();
toast("저장했습니다.");
persistLinksIfServerMode();
return;
}
@@ -1243,6 +1415,7 @@
closeModal();
render();
toast("추가했습니다.");
persistLinksIfServerMode();
}
function wire() {
@@ -1318,7 +1491,16 @@
wire();
await hydrateAuthConfigFromServerIfNeeded();
await initAuth();
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]));
el.sort.value = state.sortKey;
applyManageLock();