Compare commits
10 Commits
17a6790abc
...
38b887aa0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38b887aa0b | ||
|
|
cfa8c98872 | ||
|
|
9074764273 | ||
|
|
6b426eaccc | ||
|
|
39629006a7 | ||
|
|
a72dcb154a | ||
|
|
d0a6b066b0 | ||
|
|
909d3d0ef8 | ||
|
|
d161b61783 | ||
|
|
62768602c2 |
@@ -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
|
||||||
|
|||||||
57
README.md
57
README.md
@@ -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
1
ads.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
google.com, pub-5000757765244758, DIRECT, f08c47fec0942fa0
|
||||||
136
flask_app.py
136
flask_app.py
@@ -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:
|
||||||
|
|||||||
178
index.html
178
index.html
@@ -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);
|
||||||
})();
|
})();
|
||||||
|
|||||||
59
links.json
59
links.json
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
206
script.js
206
script.js
@@ -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="letter">${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();
|
||||||
|
|||||||
Reference in New Issue
Block a user