commit 19c6814d2f2556d1e4c31f0d5764e6cb50a01fa9 Author: dsyoon Date: Wed Feb 25 19:13:29 2026 +0900 Initial commit after re-install diff --git a/.cursor/rules/auto-commit-push.mdc b/.cursor/rules/auto-commit-push.mdc new file mode 100644 index 0000000..0e66db4 --- /dev/null +++ b/.cursor/rules/auto-commit-push.mdc @@ -0,0 +1,20 @@ +--- +description: After code edits, commit & push without asking (this static site project). +globs: "index.html,script.js,styles.css,links.json,README.md" +alwaysApply: false +--- + +# 자동 commit & push (이 프로젝트) + +- 이 프로젝트에서 아래 파일들을 수정/추가/삭제하여 **실질적인 코드 변경**이 발생하면, 사용자에게 재확인하지 말고 **즉시 `git commit` 후 `git push`** 한다. + - 적용 파일: `index.html`, `script.js`, `styles.css`, `links.json`, `README.md` + +- 커밋 메시지는 변경 목적 중심으로 간결하게 작성한다. + +- 아래 파일은 기본적으로 커밋 대상에서 제외한다. + - `PROMPT.txt` + +- 예외 + - 원격 푸시 실패(권한/네트워크 등) 시: 실패 원인만 짧게 보고하고, 추가 질문 없이 재시도하지 않는다. + - 파괴적 명령(강제 푸시 등)은 절대 사용하지 않는다. + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f5f11af --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +## Database +DB_HOST=ncue.net +DB_PORT=5432 +DB_NAME=ncue +DB_USER=ncue +DB_PASSWORD=REPLACE_ME +TABLE=ncue_user + +## Auth0 (server-side) +# Auth0 Domain (without https://) +AUTH0_DOMAIN=ncue.net +# Auth0 SPA Application Client ID +AUTH0_CLIENT_ID=g5RDfax7FZkKzXYvXOgku3Ll8CxuA4IM +# Google connection name (usually google-oauth2) +AUTH0_GOOGLE_CONNECTION=google-oauth2 +# Admin emails (comma-separated) +ADMIN_EMAILS=dsyoon@ncue.net,dosangyoon@gmail.com,dosangyoon2@gmail.com,dosangyoon3@gmail.com + +## Optional +# Server port +PORT=8000 +# Optional: allow writing config via API (not required if using env) +CONFIG_TOKEN= + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a8388f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +node_modules/ +.env +.env.* +!.env.example + +# Python +.venv/ +__pycache__/ +*.pyc + diff --git a/PROMPT.txt b/PROMPT.txt new file mode 100644 index 0000000..694a0c6 --- /dev/null +++ b/PROMPT.txt @@ -0,0 +1,21 @@ +https://ncue.net/dsyoon +https://ncue.net/family +https://ncue.net/dreamgirl +https://git.ncue.net/ +https://mail.ncue.net/ +https://tts.ncue.net/은 +https://meeting.ncue.net +https://openclaw.ncue.net +https://link.ncue.net + + +위 5개는 개인 홈페이지에 link로써 붙여둘 기술입니다. +앞으로 새로운 서비스가 만들어질 때마다 홈페이지에 붙여나갈 생각입니다. +이 링크를 모아서 관리할 수 있도록 개인 홈페이지를 만들어 주세요. + +개발 코드는 사용하지 말고 보기 좋은 이미지, html, js (jquery 등) css 등으로 쉽게 구성해주세요. + + + +여기에 로그인 기능 추가가 가능할까요? +구글 로그인, 카카오 로그인, 네이버 로그인 모두 좋습니다. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..57a8f0c --- /dev/null +++ b/README.md @@ -0,0 +1,242 @@ +# Links (개인 링크 홈) + +정적 파일(HTML/CSS/JS)만으로 만든 개인 링크 대시보드입니다. + +## 사용법 + +- **가장 간단한 방법**: `index.html`을 브라우저로 열기 + - 즐겨찾기/추가/편집/삭제/정렬/검색/가져오기/내보내기 기능은 정상 동작합니다. + - 기본 링크 목록은 `index.html` 내부의 `linksData`(JSON)에서 읽기 때문에 **파이썬 실행 없이도** 순서가 그대로 반영됩니다. + +- (선택) `links.json`을 별도 파일로 운용하고 싶다면 로컬 서버로 실행 + +```bash +python3 -m http.server 8000 +``` + +그 후 브라우저에서 `http://localhost:8000`으로 접속합니다. + +## 백엔드(Flask) + PostgreSQL 사용자 저장 + +로그인 후 사용자 정보를 `ncue_user`에 저장하고(Upsert), 로그인/로그아웃 시간을 기록하며, +`/api/config/auth`로 Auth0 설정을 공유하려면 백엔드 서버가 필요합니다. + +현재 백엔드는 **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 +psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f db/schema.sql +``` + +### 2) 실행 방법 A: (로컬/간단) venv로 실행 + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +PORT=8023 python flask_app.py +``` + +### 3) 실행 방법 B: (운영/권장) Miniconda 환경 `ncue` + gunicorn + systemd + +#### B-1) 최초 1회 설치 + +```bash +cd /path/to/home +conda activate ncue +python -m pip install -r requirements.txt +python -m pip install gunicorn +``` + +#### B-2) 단독 실행(테스트) + +```bash +conda activate ncue +cd /path/to/home +gunicorn -w 2 -b 127.0.0.1:8023 flask_app:app +``` + +확인: + +```bash +curl -s http://127.0.0.1:8023/healthz +curl -s http://127.0.0.1:8023/api/config/auth +``` + +#### B-3) systemd 서비스(예시) + +`/etc/systemd/system/ncue-flask.service`: + +```ini +[Unit] +Description=NCUE Flask API (gunicorn) +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/path/to/home + +# miniconda 경로는 설치 위치에 맞게 수정하세요. +ExecStart=/bin/bash -lc 'source /opt/miniconda3/etc/profile.d/conda.sh && conda activate ncue && gunicorn -w 2 -b 127.0.0.1:8023 flask_app:app' +Restart=always +RestartSec=3 + +# (권장) .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 +sudo systemctl daemon-reload +sudo systemctl enable --now ncue-flask +sudo systemctl restart ncue-flask +sudo systemctl status ncue-flask --no-pager +``` + +로그 확인: + +```bash +sudo journalctl -u ncue-flask -n 200 --no-pager +``` + +### 4) Apache 설정(정적은 Apache, `/api/*`만 8023 프록시) + +필요 모듈(Ubuntu/Debian): + +```bash +sudo a2enmod proxy proxy_http headers +sudo systemctl reload apache2 +``` + +가상호스트 예시: + +```apacheconf + + ServerName ncue.net + + DocumentRoot /path/to/home + + Require all granted + + + ProxyPreserveHost On + RequestHeader set X-Forwarded-Proto "https" + + ProxyPass /api/ http://127.0.0.1:8023/api/ + ProxyPassReverse /api/ http://127.0.0.1:8023/api/ + ProxyPass /healthz http://127.0.0.1:8023/healthz + ProxyPassReverse /healthz http://127.0.0.1:8023/healthz + +``` + +적용: + +```bash +sudo apachectl -t && sudo systemctl reload apache2 +``` + +### 5) 503(Service Unavailable) 트러블슈팅 체크리스트 + +브라우저 콘솔에서 503이 뜨면 대부분 **Apache가 127.0.0.1:8023 백엔드로 프록시했는데 백엔드가 응답을 못하는 상태**입니다. + +- 백엔드가 살아있는지: + +```bash +curl -i http://127.0.0.1:8023/healthz +curl -i http://127.0.0.1:8023/api/config/auth +``` + +- 서비스 로그: + +```bash +sudo journalctl -u ncue-flask -n 200 --no-pager +``` + +- Apache 프록시 로그: + - Ubuntu/Debian: `/var/log/apache2/error.log` + - RHEL/CentOS: `/var/log/httpd/error_log` + +> 참고: `flask_app.py`는 DB가 일시적으로 죽어도 앱 임포트 단계에서 바로 죽지 않도록(DB pool lazy 생성) 개선되어, +> “백엔드 프로세스가 안 떠서 Apache가 503”인 케이스를 줄였습니다. + +기본적으로 `.env`의 `ADMIN_EMAILS`에 포함된 이메일은 `can_manage=true`로 자동 승격됩니다. + +### (선택) 역프록시/분리 배포 + +- Flask가 정적까지 함께 서빙: `http://ncue.net:8023`로 접속 (same-origin) +- 정적은 별도 호스팅 + API만 Flask: `index.html`의 `window.AUTH_CONFIG.apiBase`에 API 주소를 넣고, + Flask에서는 `CORS_ORIGINS`로 허용 도메인을 지정하세요. + +최초 로그인 사용자는 DB에 저장되지만 `can_manage=false`입니다. 관리 권한을 주려면: + +```sql +update ncue_user set can_manage = true where email = 'me@example.com'; +``` + +## 로그인(관리 기능 잠금) + +이 프로젝트는 **정적 사이트**에서 동작하도록, 관리 기능(추가/편집/삭제/가져오기)을 **로그인 후(관리자 이메일)** 에만 활성화할 수 있습니다. + +- **지원 방식**: Auth0 SPA SDK + Auth0 Universal Login +- **구글/카카오/네이버**: Auth0 대시보드에서 Social/Custom OAuth 연결로 구성합니다. + +설정 방법(.env): + +1. Auth0에서 **Single Page Application** 생성 +2. `.env`에 아래 값을 설정 + - `AUTH0_DOMAIN` + - `AUTH0_CLIENT_ID` + - `AUTH0_GOOGLE_CONNECTION` (예: `google-oauth2`) + - `ADMIN_EMAILS` (예: `dosangyoon@gmail.com,dsyoon@ncue.net`) +3. Auth0 Application 설정에서 아래 URL들을 등록 + - 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(); +``` + +## 데이터 저장 + +- 기본 링크: `links.json` +- 사용자가 추가/편집/삭제한 내용: 브라우저 `localStorage`에 저장됩니다. +- 내보내기: 현재 화면 기준 링크를 JSON으로 다운로드합니다. +- 가져오기: 내보내기 JSON(배열 또는 `{links:[...]}`)을 다시 불러옵니다. diff --git a/ads.txt b/ads.txt new file mode 100644 index 0000000..754ba44 --- /dev/null +++ b/ads.txt @@ -0,0 +1 @@ +google.com, pub-5000757765244758, DIRECT, f08c47fec0942fa0 diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..cc40004 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,41 @@ +-- NCUE user table for admin gating / auditing +-- Run: psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -f db/schema.sql + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'ncue_user' + ) THEN + CREATE TABLE public.ncue_user ( + sub text PRIMARY KEY, + email text, + name text, + picture text, + provider text, + first_login_at timestamptz, + last_login_at timestamptz, + last_logout_at timestamptz, + can_manage boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ); + + CREATE INDEX idx_ncue_user_email ON public.ncue_user (email); + END IF; +END $$; + +-- Backward-compatible migration (if table already exists) +ALTER TABLE public.ncue_user ADD COLUMN IF NOT EXISTS first_login_at timestamptz; +ALTER TABLE public.ncue_user ADD COLUMN IF NOT EXISTS last_logout_at timestamptz; + +-- App config (shared across browsers) +CREATE TABLE IF NOT EXISTS public.ncue_app_config ( + key text PRIMARY KEY, + value jsonb NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + + diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..781c16c Binary files /dev/null and b/favicon.ico differ diff --git a/flask_app.py b/flask_app.py new file mode 100644 index 0000000..b5ce23c --- /dev/null +++ b/flask_app.py @@ -0,0 +1,591 @@ +from __future__ import annotations + +import json +import os +import re +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, redirect, request, send_from_directory +from flask_cors import CORS + + +load_dotenv() + + +def env(name: str, default: str = "") -> str: + return str(os.getenv(name, default)) + + +_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + + +def safe_ident(s: str) -> str: + v = str(s or "").strip() + if not _IDENT_RE.match(v): + raise RuntimeError("Invalid TABLE identifier") + return v + + +def parse_csv(s: str) -> list[str]: + return [x.strip() for x in str(s or "").split(",") if x.strip()] + + +def parse_email_csv(s: str) -> list[str]: + return [x.lower() for x in parse_csv(s)] + + +PORT = int(env("PORT", "8023") or "8023") + +DB_HOST = env("DB_HOST", "").strip() +DB_PORT = int(env("DB_PORT", "5432") or "5432") +DB_NAME = env("DB_NAME", "").strip() +DB_USER = env("DB_USER", "").strip() +DB_PASSWORD = env("DB_PASSWORD", "").strip() +DB_SSLMODE = env("DB_SSLMODE", "prefer").strip() or "prefer" +DB_CONNECT_TIMEOUT = int(env("DB_CONNECT_TIMEOUT", "5") or "5") + +TABLE = safe_ident(env("TABLE", "ncue_user") or "ncue_user") +CONFIG_TABLE = "ncue_app_config" +CONFIG_TOKEN = env("CONFIG_TOKEN", "").strip() + +ADMIN_EMAILS = set(parse_email_csv(env("ADMIN_EMAILS", "dosangyoon@gmail.com,dsyoon@ncue.net"))) + +# Auth0 config via .env (preferred) +AUTH0_DOMAIN = env("AUTH0_DOMAIN", "").strip() +AUTH0_CLIENT_ID = env("AUTH0_CLIENT_ID", "").strip() +AUTH0_GOOGLE_CONNECTION = env("AUTH0_GOOGLE_CONNECTION", "").strip() + +# Optional CORS (for static on different origin) +CORS_ORIGINS = env("CORS_ORIGINS", "*").strip() or "*" + +_POOL: Optional[psycopg2.pool.SimpleConnectionPool] = None + + +def db_configured() -> bool: + return bool(DB_HOST and DB_NAME and DB_USER and DB_PASSWORD) + + +def get_pool() -> psycopg2.pool.SimpleConnectionPool: + """ + Lazy DB pool creation. + - prevents app import failure (which causes Apache 503) when DB is temporarily unavailable + - /api/config/auth can still work purely from .env without DB + """ + global _POOL + if _POOL is not None: + return _POOL + if not db_configured(): + raise RuntimeError("db_not_configured") + _POOL = psycopg2.pool.SimpleConnectionPool( + minconn=1, + maxconn=10, + host=DB_HOST, + port=DB_PORT, + dbname=DB_NAME, + user=DB_USER, + password=DB_PASSWORD, + sslmode=DB_SSLMODE, + connect_timeout=DB_CONNECT_TIMEOUT, + ) + return _POOL + + +def db_exec(sql: str, params: Tuple[Any, ...] = ()) -> None: + pool = get_pool() + conn = pool.getconn() + try: + conn.autocommit = True + with conn.cursor() as cur: + cur.execute(sql, params) + finally: + pool.putconn(conn) + + +def db_one(sql: str, params: Tuple[Any, ...] = ()) -> Optional[tuple]: + pool = get_pool() + conn = pool.getconn() + try: + conn.autocommit = True + with conn.cursor() as cur: + cur.execute(sql, params) + row = cur.fetchone() + return row + finally: + pool.putconn(conn) + + +def ensure_user_table() -> None: + db_exec( + f""" + create table if not exists public.{TABLE} ( + sub text primary key, + email text, + name text, + picture text, + provider text, + first_login_at timestamptz, + last_login_at timestamptz, + last_logout_at timestamptz, + can_manage boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ) + """ + ) + db_exec(f"create index if not exists idx_{TABLE}_email on public.{TABLE} (email)") + db_exec(f"alter table public.{TABLE} add column if not exists first_login_at timestamptz") + db_exec(f"alter table public.{TABLE} add column if not exists last_logout_at timestamptz") + + +def ensure_config_table() -> None: + db_exec( + f""" + create table if not exists public.{CONFIG_TABLE} ( + key text primary key, + value jsonb not null, + updated_at timestamptz not null default now() + ) + """ + ) + + +def is_admin_email(email: str) -> bool: + e = str(email or "").strip().lower() + return e in ADMIN_EMAILS + + +def bearer_token() -> str: + h = request.headers.get("Authorization", "") + m = re.match(r"^Bearer\s+(.+)$", h, flags=re.IGNORECASE) + 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 + - 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 + fetched_at: float + keys: dict + + +_JWKS_CACHE: Dict[str, JwksCacheEntry] = {} +_JWKS_TTL_SECONDS = 60 * 15 + + +def _jwks_url(issuer: str) -> str: + iss = issuer.rstrip("/") + "/" + return iss + ".well-known/jwks.json" + + +def fetch_jwks(issuer: str) -> dict: + url = _jwks_url(issuer) + now = time.time() + cached = _JWKS_CACHE.get(url) + if cached and (now - cached.fetched_at) < _JWKS_TTL_SECONDS: + return cached.keys + + r = requests.get(url, timeout=5) + r.raise_for_status() + keys = r.json() + if not isinstance(keys, dict) or "keys" not in keys: + raise RuntimeError("invalid_jwks") + _JWKS_CACHE[url] = JwksCacheEntry(jwks_url=url, fetched_at=now, keys=keys) + return keys + + +def verify_id_token(id_token: str, issuer: str, audience: str) -> dict: + # 1) read header -> kid + header = jwt.get_unverified_header(id_token) + kid = header.get("kid") + if not kid: + raise RuntimeError("missing_kid") + + jwks = fetch_jwks(issuer) + key = None + for k in jwks.get("keys", []): + if k.get("kid") == kid: + key = k + break + if not key: + raise RuntimeError("kid_not_found") + + public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key)) + payload = jwt.decode( + id_token, + key=public_key, + algorithms=["RS256"], + audience=audience, + issuer=issuer.rstrip("/") + "/", + options={"require": ["exp", "iat", "iss", "aud", "sub"]}, + ) + if not isinstance(payload, dict): + raise RuntimeError("invalid_payload") + return payload + + +ROOT_DIR = Path(__file__).resolve().parent +LINKS_FILE = ROOT_DIR / "links.json" + +app = Flask(__name__) + +if CORS_ORIGINS: + origins = CORS_ORIGINS if CORS_ORIGINS == "*" else [o.strip() for o in CORS_ORIGINS.split(",") if o.strip()] + CORS(app, resources={r"/api/*": {"origins": origins}}) + + +ALLOWED_STATIC = { + "index.html", + "styles.css", + "script.js", + "links.json", + "favicon.ico", +} + + +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") + + +@app.get("/") +def static_files(filename: str) -> Response: + # Prevent exposing .env etc. Serve only allowlisted files. + if filename not in ALLOWED_STATIC: + return jsonify({"ok": False, "error": "not_found"}), 404 + 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: + if not db_configured(): + return jsonify({"ok": False, "error": "db_not_configured"}), 500 + row = db_one("select 1 as ok") + if not row: + return jsonify({"ok": False}), 500 + return jsonify({"ok": True}) + except Exception: + # Keep response minimal but actionable + return jsonify({"ok": False, "error": "db_connect_failed"}), 500 + + +@app.post("/api/auth/sync") +def api_auth_sync() -> Response: + try: + if not db_configured(): + return jsonify({"ok": False, "error": "db_not_configured"}), 500 + ensure_user_table() + id_token = bearer_token() + if not id_token: + return jsonify({"ok": False, "error": "missing_token"}), 401 + + 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 jsonify({"ok": False, "error": "missing_auth0_headers"}), 400 + + payload = verify_id_token(id_token, issuer=issuer, audience=audience) + + sub = str(payload.get("sub") or "").strip() + email = (str(payload.get("email")).strip().lower() if payload.get("email") else None) + name = (str(payload.get("name")).strip() if payload.get("name") else None) + picture = (str(payload.get("picture")).strip() if payload.get("picture") else None) + provider = sub.split("|", 1)[0] if "|" in sub else None + admin = bool(email and is_admin_email(email)) + + if not sub: + return jsonify({"ok": False, "error": "missing_sub"}), 400 + + sql = f""" + insert into public.{TABLE} + (sub, email, name, picture, provider, first_login_at, last_login_at, can_manage, updated_at) + values + (%s, %s, %s, %s, %s, now(), now(), %s, now()) + on conflict (sub) do update set + email = excluded.email, + name = excluded.name, + picture = excluded.picture, + provider = excluded.provider, + first_login_at = coalesce(public.{TABLE}.first_login_at, excluded.first_login_at), + last_login_at = now(), + can_manage = (public.{TABLE}.can_manage or %s), + updated_at = now() + returning can_manage, first_login_at, last_login_at, last_logout_at + """ + + row = db_one(sql, (sub, email, name, picture, provider, admin, admin)) + can_manage = bool(row[0]) if row else False + user = ( + { + "can_manage": can_manage, + "first_login_at": row[1], + "last_login_at": row[2], + "last_logout_at": row[3], + } + if row + else None + ) + return jsonify({"ok": True, "canManage": can_manage, "user": user}) + except Exception: + return jsonify({"ok": False, "error": "verify_failed"}), 401 + + +@app.post("/api/auth/logout") +def api_auth_logout() -> Response: + try: + if not db_configured(): + return jsonify({"ok": False, "error": "db_not_configured"}), 500 + ensure_user_table() + id_token = bearer_token() + if not id_token: + return jsonify({"ok": False, "error": "missing_token"}), 401 + + 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 jsonify({"ok": False, "error": "missing_auth0_headers"}), 400 + + payload = verify_id_token(id_token, issuer=issuer, audience=audience) + sub = str(payload.get("sub") or "").strip() + if not sub: + return jsonify({"ok": False, "error": "missing_sub"}), 400 + + sql = f""" + update public.{TABLE} + set last_logout_at = now(), + updated_at = now() + where sub = %s + returning last_logout_at + """ + row = db_one(sql, (sub,)) + return jsonify({"ok": True, "last_logout_at": row[0] if row else None}) + except Exception: + return jsonify({"ok": False, "error": "verify_failed"}), 401 + + +@app.get("/api/config/auth") +def api_config_auth_get() -> Response: + try: + # Prefer .env config (no UI needed) + if AUTH0_DOMAIN and AUTH0_CLIENT_ID and AUTH0_GOOGLE_CONNECTION: + return jsonify( + { + "ok": True, + "value": { + "auth0": {"domain": AUTH0_DOMAIN, "clientId": AUTH0_CLIENT_ID}, + "connections": {"google": AUTH0_GOOGLE_CONNECTION}, + "adminEmails": sorted(list(ADMIN_EMAILS)), + }, + "updated_at": None, + "source": "env", + } + ) + + if not db_configured(): + return jsonify({"ok": False, "error": "not_set"}), 404 + ensure_config_table() + row = db_one(f"select value, updated_at from public.{CONFIG_TABLE} where key = %s", ("auth",)) + if not row: + return jsonify({"ok": False, "error": "not_set"}), 404 + + value = row[0] or {} + if isinstance(value, str): + value = json.loads(value) + + if isinstance(value, dict) and "adminEmails" not in value and isinstance(value.get("allowedEmails"), list): + value["adminEmails"] = value.get("allowedEmails") + + return jsonify({"ok": True, "value": value, "updated_at": row[1], "source": "db"}) + except Exception: + 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: + if not db_configured(): + return jsonify({"ok": False, "error": "db_not_configured"}), 500 + ensure_config_table() + if not CONFIG_TOKEN: + return jsonify({"ok": False, "error": "config_token_not_set"}), 403 + + token = str(request.headers.get("X-Config-Token", "")).strip() + if token != CONFIG_TOKEN: + return jsonify({"ok": False, "error": "forbidden"}), 403 + + body = request.get_json(silent=True) or {} + auth0 = body.get("auth0") or {} + connections = body.get("connections") or {} + + admin_emails = body.get("adminEmails") + if not isinstance(admin_emails, list): + # legacy + admin_emails = body.get("allowedEmails") + if not isinstance(admin_emails, list): + admin_emails = [] + + domain = str(auth0.get("domain") or "").strip() + client_id = str(auth0.get("clientId") or "").strip() + google_conn = str(connections.get("google") or "").strip() + emails = [str(x).strip().lower() for x in admin_emails if str(x).strip()] + + if not domain or not client_id or not google_conn: + return jsonify({"ok": False, "error": "missing_fields"}), 400 + + value = {"auth0": {"domain": domain, "clientId": client_id}, "connections": {"google": google_conn}, "adminEmails": emails} + + sql = f""" + insert into public.{CONFIG_TABLE} (key, value, updated_at) + values (%s, %s::jsonb, now()) + on conflict (key) do update set value = excluded.value, updated_at = now() + """ + db_exec(sql, ("auth", json.dumps(value))) + return jsonify({"ok": True}) + except Exception: + return jsonify({"ok": False, "error": "server_error"}), 500 + + +if __name__ == "__main__": + # Production should run behind a reverse proxy (nginx) or gunicorn. + app.run(host="0.0.0.0", port=PORT, debug=False) + diff --git a/index.html b/index.html new file mode 100644 index 0000000..4ccd687 --- /dev/null +++ b/index.html @@ -0,0 +1,1108 @@ + + + + + + + + + + NCue | 개인 링크 홈 + + + + + + + + + + + +
+
+
+ +
+
NCue
+
개인 링크 관리
+
+
+ +
+ + + + + + + + +
+
+
+ +
+
+
+ + + + + + +
+
+
+ +
+ +
+
표시할 링크가 없습니다.
+
상단의 “추가” 버튼으로 새 링크를 등록하세요.
+
+
+ + + + + + + + + + + + + + + + + + + diff --git a/links.json b/links.json new file mode 100644 index 0000000..b1c633d --- /dev/null +++ b/links.json @@ -0,0 +1,133 @@ +[ + { + "id": "dsyoon-ncue-net", + "title": "DSYoon", + "url": "https://ncue.net/dsyoon", + "description": "개인 페이지", + "tags": [ + "personal", + "ncue" + ], + "favorite": false, + "createdAt": "2026-02-07T00:00:00.000Z", + "updatedAt": "2026-02-07T00:00:00.000Z" + }, + { + "id": "family-ncue-net", + "title": "Family", + "url": "https://ncue.net/family", + "description": "Family", + "tags": [ + "personal", + "ncue" + ], + "favorite": false, + "createdAt": "2026-02-07T00:00:00.000Z", + "updatedAt": "2026-02-07T00:00:00.000Z" + }, + { + "id": "link-ncue-net", + "title": "Link", + "url": "https://link.ncue.net/", + "description": "NCUE 링크 허브", + "tags": [ + "link", + "ncue" + ], + "favorite": false, + "createdAt": "2026-02-07T00:00:00.000Z", + "updatedAt": "2026-02-07T00:00:00.000Z" + }, + { + "id": "dreamgirl-ncue-net", + "title": "DreamGirl", + "url": "https://ncue.net/dreamgirl", + "description": "DreamGirl", + "tags": [ + "personal", + "ncue" + ], + "favorite": false, + "createdAt": "2026-02-07T00:00:00.000Z", + "updatedAt": "2026-02-07T00:00:00.000Z" + }, + { + "id": "tts-ncue-net", + "title": "TTS", + "url": "https://tts.ncue.net/", + "description": "입력한 text를 mp3로 변환", + "tags": [ + "text", + "mp3", + "ncue" + ], + "favorite": false, + "createdAt": "2026-02-07T00:00:00.000Z", + "updatedAt": "2026-02-07T00:00:00.000Z" + }, + { + "id": "meeting-ncue-net", + "title": "Meeting", + "url": "https://meeting.ncue.net/", + "description": "NCUE 미팅", + "tags": [ + "meeting", + "ncue" + ], + "favorite": false, + "createdAt": "2026-02-07T00:00:00.000Z", + "updatedAt": "2026-02-07T00:00:00.000Z" + }, + { + "id": "git-ncue-net", + "title": "Git", + "url": "https://git.ncue.net/", + "description": "NCUE Git 서비스", + "tags": [ + "dev", + "ncue" + ], + "favorite": false, + "createdAt": "2026-02-07T00:00:00.000Z", + "updatedAt": "2026-02-07T00:00:00.000Z" + }, + { + "id": "mail-ncue-net", + "title": "Mail", + "url": "https://mail.ncue.net/", + "description": "NCUE 메일", + "tags": [ + "mail", + "ncue" + ], + "favorite": false, + "createdAt": "2026-02-07T00:00:00.000Z", + "updatedAt": "2026-02-07T00:00:00.000Z" + }, + { + "id": "openclaw-ncue-net", + "title": "OpenClaw", + "url": "https://openclaw.ncue.net/", + "description": "OpenClaw", + "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" + } +] diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fd4a7ac --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1007 @@ +{ + "name": "ncue-links-dashboard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ncue-links-dashboard", + "version": "1.0.0", + "dependencies": { + "dotenv": "^16.6.1", + "express": "^4.21.2", + "helmet": "^7.2.0", + "jose": "^5.9.6", + "pg": "^8.13.1" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d8e43d4 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "ncue-links-dashboard", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "dependencies": { + "dotenv": "^16.6.1", + "express": "^4.21.2", + "helmet": "^7.2.0", + "jose": "^5.9.6", + "pg": "^8.13.1" + } +} + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..adc8de2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask +python-dotenv +psycopg2-binary +PyJWT[crypto] +requests +flask-cors + diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..fec11af --- /dev/null +++ b/run.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +systemctl $1 ncue-flask diff --git a/script.js b/script.js new file mode 100644 index 0000000..dfa214d --- /dev/null +++ b/script.js @@ -0,0 +1,1514 @@ +(() => { + "use strict"; + + // If index.html inline fallback already booted, do nothing. + if (globalThis.__LINKS_APP_BOOTED__) return; + // Mark boot so index.html fallback won't run + globalThis.__LINKS_APP_BOOTED__ = true; + + const STORAGE_KEY = "links_home_v1"; + const THEME_KEY = "links_home_theme_v1"; + const AUTH_TOAST_ONCE_KEY = "links_home_auth_toast_once_v1"; + const AUTH_OVERRIDE_KEY = "links_home_auth_override_v1"; + const AUTH_PKCE_KEY = "links_home_auth_pkce_v1"; + const AUTH_TOKEN_KEY = "links_home_auth_tokens_v1"; + + const el = { + subtitle: document.getElementById("subtitle"), + q: document.getElementById("q"), + sort: document.getElementById("sort"), + onlyFav: document.getElementById("onlyFav"), + meta: document.getElementById("meta"), + grid: document.getElementById("grid"), + empty: document.getElementById("empty"), + btnAdd: document.getElementById("btnAdd"), + btnImport: document.getElementById("btnImport"), + btnExport: document.getElementById("btnExport"), + btnTheme: document.getElementById("btnTheme"), + user: document.getElementById("user"), + userText: document.getElementById("userText"), + btnLogout: document.getElementById("btnLogout"), + snsLogin: document.getElementById("snsLogin"), + btnGoogle: document.getElementById("btnGoogle"), + modal: document.getElementById("modal"), + btnClose: document.getElementById("btnClose"), + btnCancel: document.getElementById("btnCancel"), + form: document.getElementById("form"), + id: document.getElementById("id"), + title: document.getElementById("title"), + url: document.getElementById("url"), + description: document.getElementById("description"), + tags: document.getElementById("tags"), + favorite: document.getElementById("favorite"), + file: document.getElementById("file"), + toast: document.getElementById("toast"), + }; + + // NOTE: + // 예전에는 links.json을 못 읽는 환경(file:// 등)에서 "내장 기본 목록"으로 조용히 대체했는데, + // 그러면 links.json의 순서/내용 변경이 반영되지 않아 혼란이 생깁니다. + // 이제는 links.json 로드를 우선하며, 실패 시 경고를 띄우고 빈 목록(또는 localStorage 커스텀)으로 동작합니다. + const DEFAULT_LINKS_INLINE = []; + + const state = { + baseLinks: [], + baseOrder: new Map(), + store: loadStore(), + query: "", + sortKey: "json", + onlyFav: false, + canManage: false, + serverMode: false, // true when /api/links is available + }; + + // Access levels (open/copy) + const ACCESS_ANON_IDS = new Set(["dsyoon-ncue-net", "family-ncue-net", "link-ncue-net"]); + const ACCESS_USER_IDS = new Set([ + "dsyoon-ncue-net", + "family-ncue-net", + "tts-ncue-net", + "meeting-ncue-net", + "link-ncue-net", + "dreamgirl-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) : ""; + return e.trim().toLowerCase(); + } + + function isAdminEmail(email) { + const e = String(email || "").trim().toLowerCase(); + const cfg = getAuthConfig(); + const admins = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : []; + if (admins.length) return admins.includes(e); + return DEFAULT_ADMIN_EMAILS.has(e); + } + + function canAccessLink(link) { + const email = getUserEmail(); + if (email && isAdminEmail(email)) return true; + const id = String(link && link.id ? link.id : ""); + if (email) return ACCESS_USER_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 = { + client: null, + user: null, + authorized: false, + ready: false, + mode: "disabled", // enabled | misconfigured | sdk_missing | disabled + serverCanManage: null, + idTokenRaw: "", + }; + + function nowIso() { + return new Date().toISOString(); + } + + function safeJsonParse(s, fallback) { + try { + return JSON.parse(s); + } catch { + return fallback; + } + } + + function loadStore() { + const raw = localStorage.getItem(STORAGE_KEY); + const data = raw ? safeJsonParse(raw, null) : null; + const store = { + overridesById: {}, + tombstones: [], + custom: [], + }; + if (!data || typeof data !== "object") return store; + if (data.overridesById && typeof data.overridesById === "object") store.overridesById = data.overridesById; + if (Array.isArray(data.tombstones)) store.tombstones = data.tombstones; + if (Array.isArray(data.custom)) store.custom = data.custom; + return store; + } + + function saveStore() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.store)); + } + + function normalizeUrl(url) { + const u = String(url || "").trim(); + if (!u) return ""; + if (/^https?:\/\//i.test(u)) return u; + return "https://" + u; + } + + function normalizeTags(tagsText) { + if (!tagsText) return []; + return String(tagsText) + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + .slice(0, 12); + } + + function getDomain(url) { + try { + return new URL(url).host; + } catch { + return url.replace(/^https?:\/\//i, "").split("/")[0] || ""; + } + } + + function faviconCandidates(url) { + try { + const u = new URL(url); + 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 { 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", `
${escapeHtml(letter || "L")}
`); + } + }, + { passive: true } + ); + } + } + + function escapeHtml(s) { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function idFromUrl(url) { + const d = getDomain(url).toLowerCase(); + const cleaned = d.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + return cleaned || "link"; + } + + function newId(prefix = "custom") { + if (globalThis.crypto && crypto.randomUUID) return `${prefix}-${crypto.randomUUID()}`; + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + } + + function normalizeLink(link) { + const url = normalizeUrl(link.url); + const id = String(link.id || "").trim() || idFromUrl(url) || newId("link"); + const title = String(link.title || "").trim() || getDomain(url) || "Link"; + const description = String(link.description || "").trim(); + const tags = Array.isArray(link.tags) ? link.tags.map((t) => String(t).trim()).filter(Boolean) : []; + const favorite = Boolean(link.favorite); + const createdAt = String(link.createdAt || nowIso()); + const updatedAt = String(link.updatedAt || createdAt); + return { id, title, url, description, tags, favorite, createdAt, updatedAt }; + } + + 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 || {}; + + const byId = new Map(); + for (const base of state.baseLinks) { + if (!base || !base.id) continue; + if (tomb.has(base.id)) continue; + const o = overrides[base.id]; + byId.set(base.id, { ...base, ...(o || {}) }); + } + for (const c of state.store.custom || []) { + const n = normalizeLink(c); + byId.set(n.id, n); + } + return [...byId.values()]; + } + + function matchesQuery(link, q) { + if (!q) return true; + const hay = [ + link.title, + link.url, + getDomain(link.url), + link.description || "", + (link.tags || []).join(" "), + ] + .join(" ") + .toLowerCase(); + return hay.includes(q); + } + + function toTime(s) { + const t = Date.parse(String(s || "")); + return Number.isFinite(t) ? t : 0; + } + + function orderKey(link) { + const idx = state.baseOrder.get(link.id); + if (typeof idx === "number") return idx; + // custom/imported links go after base list, in creation order + return 1_000_000 + toTime(link.createdAt); + } + + function compareLinks(a, b) { + const key = state.sortKey; + if (key === "json") { + const oa = orderKey(a); + const ob = orderKey(b); + if (oa !== ob) return oa - ob; + return a.title.localeCompare(b.title, "ko"); + } + if (key === "favorite") { + if (a.favorite !== b.favorite) return a.favorite ? -1 : 1; + // tie-breaker: keep json order + const oa = orderKey(a); + const ob = orderKey(b); + if (oa !== ob) return oa - ob; + } + if (key === "name") return a.title.localeCompare(b.title, "ko"); + if (key === "domain") return getDomain(a.url).localeCompare(getDomain(b.url), "en"); + // recent (default) + return String(b.updatedAt).localeCompare(String(a.updatedAt)); + } + + function render() { + const q = state.query.trim().toLowerCase(); + const all = getMergedLinks(); + const filtered = all + .filter((l) => (state.onlyFav ? l.favorite : true)) + .filter((l) => matchesQuery(l, q)) + .sort(compareLinks); + + el.grid.innerHTML = filtered.map(cardHtml).join(""); + wireFaviconFallbacks(); + el.empty.hidden = filtered.length !== 0; + + const favCount = all.filter((l) => l.favorite).length; + el.meta.textContent = `표시 ${filtered.length}개 · 전체 ${all.length}개 · 즐겨찾기 ${favCount}개`; + el.subtitle.textContent = all.length ? `링크 ${all.length}개` : "개인 링크 관리"; + } + + function cardHtml(link) { + const domain = escapeHtml(getDomain(link.url)); + const title = escapeHtml(link.title); + const desc = escapeHtml(link.description || ""); + const url = escapeHtml(link.url); + const starClass = link.favorite ? "star on" : "star"; + const accessible = canAccessLink(link); + const tags = (link.tags || []).slice(0, 8); + const tagHtml = [ + link.favorite ? `★ 즐겨찾기` : "", + accessible ? "" : `접근 제한`, + ...tags.map((t) => `#${escapeHtml(t)}`), + ] + .filter(Boolean) + .join(""); + + 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 = faviconCandidates(link.url); + + const accessDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\""; + const accessDisabledTitle = accessible ? "" : " title=\"이 링크는 현재 권한으로 접근할 수 없습니다.\""; + + const openHref = escapeHtml(buildOpenUrl(link.url)); + const openHtml = accessible + ? `열기` + : ``; + + const copyDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\""; + const copyDisabledTitle = accessible ? "" : " title=\"이 링크는 현재 권한으로 접근할 수 없습니다.\""; + + return ` +
+
+
+ +
+
${title}
+
${domain}
+
+
+ +
+ +
${desc || " "}
+
${tagHtml || ""}
+ +
+ ${openHtml} + + + +
+
+ `; + } + + function openModal(mode, link) { + el.modal.hidden = false; + document.body.style.overflow = "hidden"; + const isEdit = mode === "edit"; + + document.getElementById("modalTitle").textContent = isEdit ? "링크 편집" : "링크 추가"; + el.id.value = isEdit ? link.id : ""; + el.title.value = isEdit ? link.title : ""; + el.url.value = isEdit ? link.url : ""; + el.description.value = isEdit ? link.description || "" : ""; + el.tags.value = isEdit ? (link.tags || []).join(", ") : ""; + el.favorite.checked = isEdit ? Boolean(link.favorite) : false; + + setTimeout(() => el.title.focus(), 0); + } + + function closeModal() { + el.modal.hidden = true; + document.body.style.overflow = ""; + el.form.reset(); + el.id.value = ""; + } + + function getLinkById(id) { + return getMergedLinks().find((l) => l.id === id) || null; + } + + function isBaseId(id) { + return state.baseLinks.some((l) => l.id === id); + } + + function setOverride(id, patch) { + state.store.overridesById[id] = { ...(state.store.overridesById[id] || {}), ...patch }; + saveStore(); + } + + function removeOverride(id) { + if (state.store.overridesById && state.store.overridesById[id]) { + delete state.store.overridesById[id]; + } + saveStore(); + } + + function toast(msg) { + el.toast.textContent = msg; + el.toast.hidden = false; + clearTimeout(toast._t); + toast._t = setTimeout(() => { + el.toast.hidden = true; + }, 2400); + } + + function toastOnce(key, msg) { + const k = `${AUTH_TOAST_ONCE_KEY}:${key}`; + if (localStorage.getItem(k)) return; + localStorage.setItem(k, "1"); + toast(msg); + } + + function loadAuthOverride() { + const raw = localStorage.getItem(AUTH_OVERRIDE_KEY); + const data = raw ? safeJsonParse(raw, null) : null; + if (!data || typeof data !== "object") return null; + const auth0 = data.auth0 && typeof data.auth0 === "object" ? data.auth0 : {}; + // legacy: allowedEmails -> adminEmails + const adminEmails = Array.isArray(data.adminEmails) + ? data.adminEmails + : Array.isArray(data.allowedEmails) + ? data.allowedEmails + : []; + return { + auth0: { + domain: String(auth0.domain || "").trim(), + clientId: String(auth0.clientId || "").trim(), + }, + connections: + data.connections && typeof data.connections === "object" + ? { + google: String(data.connections.google || "").trim(), + // legacy keys kept for backward compatibility + kakao: String(data.connections.kakao || "").trim(), + naver: String(data.connections.naver || "").trim(), + } + : { google: "", kakao: "", naver: "" }, + adminEmails: adminEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean), + }; + } + + function saveAuthOverride(cfg) { + localStorage.setItem(AUTH_OVERRIDE_KEY, JSON.stringify(cfg)); + } + + function clearAuthOverride() { + localStorage.removeItem(AUTH_OVERRIDE_KEY); + } + + function getAuthConfig() { + const cfg = globalThis.AUTH_CONFIG && typeof globalThis.AUTH_CONFIG === "object" ? globalThis.AUTH_CONFIG : {}; + const apiBase = String(cfg.apiBase || "").trim(); // optional, e.g. https://api.ncue.net + const auth0 = cfg.auth0 && typeof cfg.auth0 === "object" ? cfg.auth0 : {}; + // legacy: allowedEmails -> adminEmails + const adminEmails = Array.isArray(cfg.adminEmails) + ? cfg.adminEmails + : Array.isArray(cfg.allowedEmails) + ? cfg.allowedEmails + : []; + const base = { + apiBase, + auth0: { + domain: String(auth0.domain || "").trim(), + clientId: String(auth0.clientId || "").trim(), + }, + connections: { + google: "", + kakao: "", + naver: "", + }, + adminEmails: adminEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean), + }; + const override = loadAuthOverride(); + if (!override) return base; + // override가 있으면 우선 적용 (서버 재배포 없이 테스트 가능) + return { + apiBase, + auth0: { + domain: override.auth0.domain || base.auth0.domain, + clientId: override.auth0.clientId || base.auth0.clientId, + }, + connections: { + google: override.connections?.google || "", + kakao: override.connections?.kakao || "", + naver: override.connections?.naver || "", + }, + adminEmails: override.adminEmails.length ? override.adminEmails : base.adminEmails, + }; + } + + function apiUrl(pathname) { + const cfg = getAuthConfig(); + const base = cfg.apiBase; + if (!base) return pathname; // same-origin + try { + return new URL(pathname, base).toString(); + } catch { + 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 || {}; + // legacy: allowedEmails -> adminEmails + 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; + saveAuthOverride({ + auth0: { domain, clientId }, + connections: { google }, + adminEmails, + }); + return true; + } catch { + return false; + } + } + + function currentUrlNoQuery() { + // Auth0 callback 후 URL 정리용 + const u = new URL(location.href); + u.searchParams.delete("code"); + u.searchParams.delete("state"); + return u.toString(); + } + + function redirectUri() { + return location.origin === "null" ? location.href : location.origin + location.pathname; + } + + function b64urlFromBytes(bytes) { + let bin = ""; + const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]); + const b64 = btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + return b64; + } + + async function sha256Bytes(input) { + const data = new TextEncoder().encode(String(input)); + const hash = await crypto.subtle.digest("SHA-256", data); + return new Uint8Array(hash); + } + + function randomString(len = 43) { + const bytes = new Uint8Array(len); + crypto.getRandomValues(bytes); + return b64urlFromBytes(bytes); + } + + function decodeJwtPayload(token) { + try { + const parts = String(token || "").split("."); + if (parts.length < 2) return null; + const b64 = parts[1].replaceAll("-", "+").replaceAll("_", "/"); + const pad = "=".repeat((4 - (b64.length % 4)) % 4); + const json = atob(b64 + pad); + return safeJsonParse(json, null); + } catch { + return null; + } + } + + function loadTokens() { + const raw = localStorage.getItem(AUTH_TOKEN_KEY); + const data = raw ? safeJsonParse(raw, null) : null; + if (!data || typeof data !== "object") return null; + return { + id_token: typeof data.id_token === "string" ? data.id_token : "", + access_token: typeof data.access_token === "string" ? data.access_token : "", + received_at: typeof data.received_at === "string" ? data.received_at : "", + expires_in: typeof data.expires_in === "number" ? data.expires_in : 0, + }; + } + + function saveTokens(t) { + localStorage.setItem( + AUTH_TOKEN_KEY, + JSON.stringify({ + id_token: t.id_token || "", + access_token: t.access_token || "", + expires_in: t.expires_in || 0, + received_at: nowIso(), + }) + ); + } + + function clearTokens() { + localStorage.removeItem(AUTH_TOKEN_KEY); + } + + async function manualAuthorize(connection) { + const cfg = getAuthConfig(); + if (!cfg.auth0.domain || !cfg.auth0.clientId) { + toast("로그인 설정이 서버(.env)에 없습니다. 관리자에게 문의하세요."); + return; + } + if (!globalThis.crypto || !crypto.subtle) { + toast("이 브라우저는 보안 로그인(PKCE)을 지원하지 않습니다."); + return; + } + const verifier = randomString(64); + const challenge = b64urlFromBytes(await sha256Bytes(verifier)); + const state = randomString(24); + sessionStorage.setItem( + AUTH_PKCE_KEY, + JSON.stringify({ + verifier, + state, + redirect_uri: redirectUri(), + created_at: nowIso(), + }) + ); + + const u = new URL(`https://${cfg.auth0.domain}/authorize`); + u.searchParams.set("response_type", "code"); + u.searchParams.set("client_id", cfg.auth0.clientId); + u.searchParams.set("redirect_uri", redirectUri()); + u.searchParams.set("scope", "openid profile email"); + u.searchParams.set("state", state); + u.searchParams.set("code_challenge", challenge); + u.searchParams.set("code_challenge_method", "S256"); + if (connection) u.searchParams.set("connection", connection); + location.assign(u.toString()); + } + + async function manualHandleCallbackIfNeeded() { + const cfg = getAuthConfig(); + const u = new URL(location.href); + const code = u.searchParams.get("code") || ""; + const stateParam = u.searchParams.get("state") || ""; + if (!code || !stateParam) return null; + + const raw = sessionStorage.getItem(AUTH_PKCE_KEY); + const pkce = raw ? safeJsonParse(raw, null) : null; + if (!pkce || pkce.state !== stateParam) { + toast("로그인 상태값(state)이 일치하지 않습니다. 다시 시도하세요."); + return null; + } + + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("client_id", cfg.auth0.clientId); + body.set("code_verifier", pkce.verifier); + body.set("code", code); + body.set("redirect_uri", pkce.redirect_uri || redirectUri()); + + const tokenRes = await fetch(`https://${cfg.auth0.domain}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + if (!tokenRes.ok) { + toast("토큰 발급에 실패했습니다. Auth0 Callback URL 설정을 확인하세요."); + return null; + } + const tokenJson = await tokenRes.json(); + const idToken = String(tokenJson.id_token || ""); + const accessToken = String(tokenJson.access_token || ""); + const expiresIn = Number(tokenJson.expires_in || 0) || 0; + if (!idToken) { + toast("ID 토큰이 없습니다. 로그인 설정을 확인하세요."); + return null; + } + + saveTokens({ id_token: idToken, access_token: accessToken, expires_in: expiresIn }); + sessionStorage.removeItem(AUTH_PKCE_KEY); + history.replaceState({}, document.title, currentUrlNoQuery()); + return { idToken, accessToken }; + } + + async function manualLoadUser() { + const t = loadTokens(); + if (!t || !t.id_token) return null; + const payload = decodeJwtPayload(t.id_token); + if (!payload) return null; + // 최소 표시/권한 확인용 user shape + return { + sub: payload.sub, + email: payload.email, + name: payload.name, + picture: payload.picture, + }; + } + + async function syncUserToServerWithIdToken(idToken) { + try { + auth.idTokenRaw = String(idToken || ""); + const cfg = getAuthConfig(); + const r = await fetch(apiUrl("/api/auth/sync"), { + method: "POST", + headers: { + Authorization: `Bearer ${idToken}`, + "X-Auth0-Issuer": `https://${cfg.auth0.domain}/`, + "X-Auth0-ClientId": cfg.auth0.clientId, + }, + }); + if (!r.ok) { + toastOnce( + "syncfail", + `사용자 저장(API)이 실패했습니다. (${r.status}) 정적 호스팅이면 /api 를 서버로 프록시하거나 apiBase를 설정해야 합니다.` + ); + return null; + } + const data = await r.json(); + if (data && data.ok) return Boolean(data.canManage); + return null; + } catch { + toastOnce("syncerr", "사용자 저장(API)에 연결하지 못했습니다. /api 서버 연결을 확인하세요."); + return null; + } + } + + 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(); + const payload = JSON.stringify({ t: Date.now() }); + // Prefer sendBeacon to survive navigation + try { + const blob = new Blob([payload], { type: "application/json" }); + const ok = navigator.sendBeacon(apiUrl("/api/auth/logout"), blob); + if (ok) return; + } catch { + // ignore + } + // Fallback fetch keepalive (best-effort) + try { + fetch(apiUrl("/api/auth/logout"), { + method: "POST", + headers: { + Authorization: `Bearer ${idToken}`, + "X-Auth0-Issuer": `https://${cfg.auth0.domain}/`, + "X-Auth0-ClientId": cfg.auth0.clientId, + "Content-Type": "application/json", + }, + body: payload, + keepalive: true, + }).catch(() => {}); + } catch { + // ignore + } + } + + function isManageAdminEmail(email) { + const cfg = getAuthConfig(); + const admins = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : []; + const e = String(email || "").trim().toLowerCase(); + if (admins.length) return admins.includes(e); + // 안전한 기본값: 설정이 비어있으면 기본 관리자만 관리 가능 + return DEFAULT_ADMIN_EMAILS.has(e); + } + + function updateAuthUi() { + // 로그인 전에는 사용자 배지를 숨김(요청: "로그인 설정 필요" 영역 제거) + if (!auth.user) { + el.user.hidden = true; + } else { + el.user.hidden = false; + const email = auth.user && auth.user.email ? String(auth.user.email) : ""; + const name = auth.user && auth.user.name ? String(auth.user.name) : ""; + const label = email ? email : name ? name : "로그인됨"; + el.userText.textContent = label; + if (auth.authorized) el.user.setAttribute("data-auth", "ok"); + else el.user.removeAttribute("data-auth"); + } + + // 로그아웃은 로그인 된 이후에만 노출 + el.btnLogout.hidden = !auth.user; + el.btnLogout.disabled = false; + + // 간편 로그인 버튼 노출 + // - 설정 전(로그인 설정 필요)에도 디자인이 보이도록 영역은 항상 노출 + // - connection이 없으면 클릭 시 설정 모달을 띄움(핸들러에서 처리) + const cfg = getAuthConfig(); + const showQuick = !auth.user; + if (el.snsLogin) el.snsLogin.hidden = !showQuick; + if (el.btnGoogle) { + el.btnGoogle.hidden = !showQuick; + el.btnGoogle.classList.toggle("is-disabled", !cfg.connections.google); + } + } + + function applyManageLock() { + // AUTH_CONFIG가 없는 상태에서는 기존처럼 자유롭게 관리 가능. + // 로그인 기능이 "enabled"일 때만 관리 잠금을 적용합니다. + state.canManage = auth.mode === "enabled" ? Boolean(auth.user && auth.authorized) : true; + + const lockMsg = "관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다."; + el.btnAdd.disabled = !state.canManage; + el.btnImport.disabled = !state.canManage; + // 요청: 로그인 전에는 내보내기도 비활성화 + el.btnExport.disabled = auth.mode === "enabled" ? !auth.user : false; + + if (!state.canManage) { + el.btnAdd.title = lockMsg; + el.btnImport.title = lockMsg; + } else { + el.btnAdd.title = ""; + el.btnImport.title = ""; + } + + if (el.btnExport.disabled) el.btnExport.title = "내보내기는 로그인 후 사용 가능합니다."; + else el.btnExport.title = ""; + } + + async function initAuth() { + auth.ready = true; + const cfg = getAuthConfig(); + const hasAuth0 = cfg.auth0.domain && cfg.auth0.clientId; + const hasSdk = typeof globalThis.createAuth0Client === "function"; + + if (!hasAuth0) { + // 설정이 없으면: 로그인 비활성(운영자 설정 필요), 관리 기능은 잠그지 않음 + auth.client = null; + auth.user = null; + auth.authorized = false; + auth.mode = "misconfigured"; + updateAuthUi(); + applyManageLock(); + toastOnce("misconf", "로그인 설정이 서버(.env)에 없습니다. 관리자에게 문의하세요."); + return; + } + + if (!hasSdk) { + // SDK가 없어도 PKCE 수동 로그인으로 동작 가능 + auth.client = null; + auth.mode = "enabled"; + await manualHandleCallbackIfNeeded().catch(() => {}); + auth.user = await manualLoadUser(); + const email = auth.user && auth.user.email ? String(auth.user.email) : ""; + auth.authorized = Boolean(auth.user) && isManageAdminEmail(email); + 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; + auth.authorized = can; + } + } + updateAuthUi(); + applyManageLock(); + return; + } + + if (location.protocol === "file:") { + toastOnce("file", "소셜 로그인은 보통 HTTPS 사이트에서만 동작합니다. (file://에서는 제한될 수 있어요)"); + } + + try { + auth.client = await createAuth0Client({ + domain: cfg.auth0.domain, + clientId: cfg.auth0.clientId, + authorizationParams: { + redirect_uri: redirectUri(), + }, + cacheLocation: "localstorage", + useRefreshTokens: true, + }); + auth.mode = "enabled"; + } catch { + auth.client = null; + auth.user = null; + auth.authorized = false; + auth.mode = "sdk_missing"; + toastOnce("authinit", "로그인 초기화에 실패했습니다. AUTH_CONFIG 값과 Auth0 설정(Callback URL)을 확인하세요."); + updateAuthUi(); + applyManageLock(); + return; + } + + const u = new URL(location.href); + const isCallback = u.searchParams.has("code") && u.searchParams.has("state"); + if (isCallback) { + try { + await auth.client.handleRedirectCallback(); + } finally { + history.replaceState({}, document.title, currentUrlNoQuery()); + } + } + + const isAuthed = await auth.client.isAuthenticated(); + auth.user = isAuthed ? await auth.client.getUser() : null; + const email = auth.user && auth.user.email ? auth.user.email : ""; + auth.authorized = Boolean(auth.user) && isManageAdminEmail(email); + auth.serverCanManage = null; + + // 로그인되었으면 서버에 사용자 upsert 및 can_manage 동기화(서버가 있을 때만) + if (auth.user) { + try { + 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", + headers: { + Authorization: `Bearer ${raw}`, + "X-Auth0-Issuer": `https://${cfg.auth0.domain}/`, + "X-Auth0-ClientId": cfg.auth0.clientId, + }, + }); + if (r.ok) { + const data = await r.json(); + if (data && data.ok) { + auth.serverCanManage = Boolean(data.canManage); + auth.authorized = auth.serverCanManage; + } + } + } + } catch { + // ignore: server not running or blocked + } + } + + if (auth.user && !auth.authorized) { + toastOnce("deny", "로그인은 되었지만 관리자 이메일이 아니라서 관리 기능이 잠금 상태입니다."); + } + + updateAuthUi(); + applyManageLock(); + } + + async function login() { + if (auth.mode !== "enabled" || !auth.client) { + toast("로그인 설정이 서버(.env)에 필요합니다."); + return; + } + await auth.client.loginWithRedirect(); + } + + async function loginWithConnection(connection) { + if (auth.mode !== "enabled") { + toast("로그인 설정이 서버(.env)에 필요합니다."); + return; + } + if (auth.client) { + await auth.client.loginWithRedirect({ + authorizationParams: { connection }, + }); + return; + } + await manualAuthorize(connection); + } + + async function logout() { + if (auth.mode !== "enabled") return; + // SDK가 있으면 SDK로, 없으면 수동 로그아웃 + if (auth.client) { + try { + const claims = await auth.client.getIdTokenClaims(); + const raw = claims && claims.__raw ? String(claims.__raw) : ""; + if (raw) sendLogoutToServer(raw); + } catch { + // ignore + } + auth.user = null; + auth.authorized = false; + updateAuthUi(); + applyManageLock(); + await auth.client.logout({ + logoutParams: { + returnTo: redirectUri(), + }, + }); + return; + } + // manual token logout + const t = loadTokens(); + if (t && t.id_token) sendLogoutToServer(t.id_token); + clearTokens(); + auth.user = null; + auth.authorized = false; + updateAuthUi(); + applyManageLock(); + const cfg = getAuthConfig(); + const u = new URL(`https://${cfg.auth0.domain}/v2/logout`); + u.searchParams.set("client_id", cfg.auth0.clientId); + u.searchParams.set("returnTo", redirectUri()); + location.assign(u.toString()); + } + + async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + toast("복사했습니다."); + } catch { + // fallback + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", "readonly"); + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + ta.remove(); + toast("복사했습니다."); + } + } + + 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; + else state.store.custom.push(n); + saveStore(); + } + + 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); + state.store.tombstones = [...s]; + removeOverride(id); + saveStore(); + toast("기본 링크를 숨겼습니다."); + return; + } + const before = state.store.custom.length; + state.store.custom = (state.store.custom || []).filter((c) => c && c.id !== id); + saveStore(); + if (state.store.custom.length !== before) toast("삭제했습니다."); + } + + function toggleFavorite(id) { + 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 { + upsertCustom({ ...link, favorite: next, updatedAt: nowIso() }); + } + render(); + } + + function editLink(id) { + const link = getLinkById(id); + if (!link) return; + openModal("edit", link); + } + + async function loadBaseLinks() { + // 1) index.html 내부 내장 데이터(서버 없이도 동작) + const dataEl = document.getElementById("linksData"); + if (dataEl && dataEl.textContent) { + const parsed = safeJsonParse(dataEl.textContent, null); + if (Array.isArray(parsed)) return parsed.map(normalizeLink); + } + + // 2) 동일 디렉토리의 links.json (서버 환경에서 권장) + const candidates = [ + new URL("./links.json", document.baseURI).toString(), + new URL("links.json", document.baseURI).toString(), + ]; + + for (const url of candidates) { + try { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) continue; + const data = await res.json(); + if (!Array.isArray(data)) continue; + return data.map(normalizeLink); + } catch { + // try next + } + } + + const hint = + location.protocol === "file:" + ? "기본 링크 데이터가 없습니다. index.html의 linksData를 확인하세요." + : "links.json을 불러오지 못했습니다. 배포 경로에 links.json이 있는지 확인하세요."; + toast(hint); + return DEFAULT_LINKS_INLINE.map(normalizeLink); + } + + function applyTheme(theme) { + const t = theme === "light" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", t); + el.btnTheme.setAttribute("aria-pressed", t === "dark" ? "true" : "false"); + localStorage.setItem(THEME_KEY, t); + } + + function initTheme() { + const saved = localStorage.getItem(THEME_KEY); + if (saved === "light" || saved === "dark") return applyTheme(saved); + const prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches; + applyTheme(prefersLight ? "light" : "dark"); + } + + function exportJson() { + const data = getMergedLinks().sort((a, b) => a.title.localeCompare(b.title, "ko")); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `links-export-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + URL.revokeObjectURL(a.href); + a.remove(); + }, 0); + toast("내보내기 파일을 생성했습니다."); + } + + function importJsonText(text) { + const parsed = safeJsonParse(text, null); + if (!parsed) throw new Error("JSON 파싱 실패"); + + const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.links) ? parsed.links : null; + if (!list) throw new Error("JSON 형식이 올바르지 않습니다. (배열 또는 {links:[...]} )"); + + const merged = getMergedLinks(); + const used = new Set(merged.map((l) => l.id)); + + let added = 0; + for (const item of list) { + if (!item) continue; + const n0 = normalizeLink(item); + let n = n0; + if (used.has(n.id)) { + n = { ...n, id: newId("import"), createdAt: nowIso(), updatedAt: nowIso() }; + } + used.add(n.id); + // 가져오기는 custom로 추가(기본과 충돌 방지) + if (state.serverMode) state.baseLinks.push(n); + else state.store.custom.push(n); + added++; + } + if (!state.serverMode) saveStore(); + state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i])); + toast(`가져오기 완료: ${added}개`); + } + + function onGridClick(e) { + const btn = e.target.closest("[data-act]"); + if (!btn) return; + const card = e.target.closest(".card"); + if (!card) return; + const id = card.getAttribute("data-id"); + if (!id) return; + + const act = btn.getAttribute("data-act"); + // access gate for open/copy + if ((act === "open" || act === "copy") && card.getAttribute("data-access") === "0") { + toast("이 링크는 현재 권한으로 접근할 수 없습니다."); + e.preventDefault(); + return; + } + if (auth.mode === "enabled" && !state.canManage && (act === "fav" || act === "edit" || act === "del")) { + toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다."); + return; + } + if (act === "fav") { + toggleFavorite(id); + persistLinksIfServerMode(); + return; + } + if (act === "copy") { + const link = getLinkById(id); + if (link) copyText(link.url); + return; + } + if (act === "edit") { + editLink(id); + return; + } + if (act === "del") { + const link = getLinkById(id); + const name = link ? link.title : id; + if (confirm(`삭제할까요?\n\n- ${name}`)) { + deleteLink(id); + render(); + persistLinksIfServerMode(); + } + return; + } + } + + function onFormSubmit(e) { + e.preventDefault(); + const isEdit = Boolean(el.id.value); + + const title = String(el.title.value || "").trim(); + const url = normalizeUrl(el.url.value); + if (!title) return toast("제목을 입력하세요."); + if (!url) return toast("URL을 입력하세요."); + + let parsed; + try { + parsed = new URL(url); + } catch { + return toast("URL 형식이 올바르지 않습니다."); + } + if (!/^https?:$/.test(parsed.protocol)) return toast("http/https URL만 지원합니다."); + + const description = String(el.description.value || "").trim(); + const tags = normalizeTags(el.tags.value); + const favorite = Boolean(el.favorite.checked); + + if (isEdit) { + const id = el.id.value; + const current = getLinkById(id); + if (!current) { + closeModal(); + toast("편집 대상이 없습니다."); + return; + } + const patch = { + title, + url, + description, + tags, + favorite, + updatedAt: nowIso(), + }; + if (isBaseId(id)) setOverride(id, patch); + else upsertCustom({ ...current, ...patch }); + closeModal(); + render(); + toast("저장했습니다."); + persistLinksIfServerMode(); + return; + } + + const id = newId("custom"); + upsertCustom({ + id, + title, + url, + description, + tags, + favorite, + createdAt: nowIso(), + updatedAt: nowIso(), + }); + closeModal(); + render(); + toast("추가했습니다."); + persistLinksIfServerMode(); + } + + function wire() { + el.q.addEventListener("input", () => { + state.query = el.q.value || ""; + render(); + }); + + el.sort.addEventListener("change", () => { + state.sortKey = el.sort.value || "json"; + render(); + }); + + el.onlyFav.addEventListener("change", () => { + state.onlyFav = Boolean(el.onlyFav.checked); + render(); + }); + + el.grid.addEventListener("click", onGridClick); + + el.btnAdd.addEventListener("click", () => { + if (auth.mode === "enabled" && !state.canManage) + return toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다."); + openModal("add", null); + }); + el.btnClose.addEventListener("click", closeModal); + el.btnCancel.addEventListener("click", closeModal); + el.modal.addEventListener("click", (e) => { + const close = e.target && e.target.getAttribute && e.target.getAttribute("data-close"); + if (close) closeModal(); + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !el.modal.hidden) closeModal(); + }); + + el.form.addEventListener("submit", onFormSubmit); + + el.btnExport.addEventListener("click", exportJson); + el.btnImport.addEventListener("click", () => { + if (auth.mode === "enabled" && !state.canManage) + return toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다."); + el.file.click(); + }); + el.file.addEventListener("change", async () => { + const f = el.file.files && el.file.files[0]; + el.file.value = ""; + if (!f) return; + try { + const text = await f.text(); + importJsonText(text); + render(); + } catch (err) { + toast(String(err && err.message ? err.message : "가져오기 실패")); + } + }); + + el.btnTheme.addEventListener("click", () => { + const cur = document.documentElement.getAttribute("data-theme") || "dark"; + applyTheme(cur === "dark" ? "light" : "dark"); + }); + + if (el.btnLogout) el.btnLogout.addEventListener("click", () => logout().catch(() => toast("로그아웃에 실패했습니다."))); + if (el.btnGoogle) + el.btnGoogle.addEventListener("click", () => { + const c = getAuthConfig().connections.google; + if (!c) return toast("서버(.env)에 AUTH0_GOOGLE_CONNECTION 설정이 필요합니다."); + return loginWithConnection(c).catch(() => toast("로그인에 실패했습니다.")); + }); + } + + async function main() { + initTheme(); + 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(); + render(); + } + + main().catch(() => { + toast("초기화에 실패했습니다."); + }); +})(); + diff --git a/server.js b/server.js new file mode 100644 index 0000000..85f8efd --- /dev/null +++ b/server.js @@ -0,0 +1,292 @@ +import "dotenv/config"; +import express from "express"; +import helmet from "helmet"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import pg from "pg"; +import { createRemoteJWKSet, jwtVerify } from "jose"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function env(name, fallback = "") { + return (process.env[name] ?? fallback).toString(); +} + +function must(name) { + const v = env(name).trim(); + if (!v) throw new Error(`Missing env: ${name}`); + return v; +} + +function safeIdent(s) { + const v = String(s || "").trim(); + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(v)) throw new Error("Invalid TABLE identifier"); + return v; +} + +function parseCsv(s) { + return String(s || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean); +} + +function parseEmailCsv(s) { + return parseCsv(s).map((x) => x.toLowerCase()); +} + +const PORT = Number(env("PORT", "8000")) || 8000; +const DB_HOST = must("DB_HOST"); +const DB_PORT = Number(env("DB_PORT", "5432")) || 5432; +const DB_NAME = must("DB_NAME"); +const DB_USER = must("DB_USER"); +const DB_PASSWORD = must("DB_PASSWORD"); +const TABLE = safeIdent(env("TABLE", "ncue_user") || "ncue_user"); +const CONFIG_TABLE = "ncue_app_config"; +const CONFIG_TOKEN = env("CONFIG_TOKEN", "").trim(); +const ADMIN_EMAILS = new Set(parseEmailCsv(env("ADMIN_EMAILS", "dosangyoon@gmail.com,dsyoon@ncue.net"))); + +// Auth0 config via .env (preferred) +const AUTH0_DOMAIN = env("AUTH0_DOMAIN", "").trim(); +const AUTH0_CLIENT_ID = env("AUTH0_CLIENT_ID", "").trim(); +const AUTH0_GOOGLE_CONNECTION = env("AUTH0_GOOGLE_CONNECTION", "").trim(); + +const pool = new pg.Pool({ + host: DB_HOST, + port: DB_PORT, + database: DB_NAME, + user: DB_USER, + password: DB_PASSWORD, + ssl: false, + max: 10, +}); + +const app = express(); + +app.use( + helmet({ + contentSecurityPolicy: false, // keep simple for static + Auth0 + }) +); +app.use(express.json({ limit: "256kb" })); + +app.get("/healthz", async (_req, res) => { + try { + await pool.query("select 1 as ok"); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ ok: false }); + } +}); + +function getBearer(req) { + const h = req.headers.authorization || ""; + const m = /^Bearer\s+(.+)$/i.exec(h); + return m ? m[1].trim() : ""; +} + +async function verifyIdToken(idToken, { issuer, audience }) { + const jwks = createRemoteJWKSet(new URL(`${issuer}.well-known/jwks.json`)); + const { payload } = await jwtVerify(idToken, jwks, { + issuer, + audience, + }); + return payload; +} + +async function ensureUserTable() { + // Create table if missing + add columns for upgrades + await pool.query(` + create table if not exists public.${TABLE} ( + sub text primary key, + email text, + name text, + picture text, + provider text, + first_login_at timestamptz, + last_login_at timestamptz, + last_logout_at timestamptz, + can_manage boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ) + `); + await pool.query(`create index if not exists idx_${TABLE}_email on public.${TABLE} (email)`); + await pool.query(`alter table public.${TABLE} add column if not exists first_login_at timestamptz`); + await pool.query(`alter table public.${TABLE} add column if not exists last_logout_at timestamptz`); +} + +async function ensureConfigTable() { + await pool.query(` + create table if not exists public.${CONFIG_TABLE} ( + key text primary key, + value jsonb not null, + updated_at timestamptz not null default now() + ) + `); +} + +function isAdminEmail(email) { + const e = String(email || "").trim().toLowerCase(); + return ADMIN_EMAILS.has(e); +} + +app.post("/api/auth/sync", async (req, res) => { + try { + await ensureUserTable(); + const idToken = getBearer(req); + if (!idToken) return res.status(401).json({ ok: false, error: "missing_token" }); + + const issuer = String(req.headers["x-auth0-issuer"] || "").trim(); + const audience = String(req.headers["x-auth0-clientid"] || "").trim(); + if (!issuer || !audience) return res.status(400).json({ ok: false, error: "missing_auth0_headers" }); + + const payload = await verifyIdToken(idToken, { issuer, audience }); + + const sub = String(payload.sub || "").trim(); + const email = payload.email ? String(payload.email).trim().toLowerCase() : null; + const name = payload.name ? String(payload.name).trim() : null; + const picture = payload.picture ? String(payload.picture).trim() : null; + const provider = sub.includes("|") ? sub.split("|", 1)[0] : null; + const isAdmin = email ? isAdminEmail(email) : false; + + if (!sub) return res.status(400).json({ ok: false, error: "missing_sub" }); + + const q = ` + insert into public.${TABLE} + (sub, email, name, picture, provider, first_login_at, last_login_at, can_manage, updated_at) + values + ($1, $2, $3, $4, $5, now(), now(), $6, now()) + on conflict (sub) do update set + email = excluded.email, + name = excluded.name, + picture = excluded.picture, + provider = excluded.provider, + first_login_at = coalesce(public.${TABLE}.first_login_at, excluded.first_login_at), + last_login_at = now(), + can_manage = (public.${TABLE}.can_manage or $6), + updated_at = now() + returning can_manage, first_login_at, last_login_at, last_logout_at + `; + const r = await pool.query(q, [sub, email, name, picture, provider, isAdmin]); + const canManage = Boolean(r.rows?.[0]?.can_manage); + + res.json({ ok: true, canManage, user: r.rows?.[0] || null }); + } catch (e) { + res.status(401).json({ ok: false, error: "verify_failed" }); + } +}); + +app.post("/api/auth/logout", async (req, res) => { + try { + await ensureUserTable(); + const idToken = getBearer(req); + if (!idToken) return res.status(401).json({ ok: false, error: "missing_token" }); + + const issuer = String(req.headers["x-auth0-issuer"] || "").trim(); + const audience = String(req.headers["x-auth0-clientid"] || "").trim(); + if (!issuer || !audience) return res.status(400).json({ ok: false, error: "missing_auth0_headers" }); + + const payload = await verifyIdToken(idToken, { issuer, audience }); + const sub = String(payload.sub || "").trim(); + if (!sub) return res.status(400).json({ ok: false, error: "missing_sub" }); + + const q = ` + update public.${TABLE} + set last_logout_at = now(), + updated_at = now() + where sub = $1 + returning last_logout_at + `; + const r = await pool.query(q, [sub]); + res.json({ ok: true, last_logout_at: r.rows?.[0]?.last_logout_at || null }); + } catch (e) { + res.status(401).json({ ok: false, error: "verify_failed" }); + } +}); + +// Shared auth config for all browsers (read-only public) +app.get("/api/config/auth", async (_req, res) => { + try { + // Prefer .env config (no UI needed) + if (AUTH0_DOMAIN && AUTH0_CLIENT_ID && AUTH0_GOOGLE_CONNECTION) { + return res.json({ + ok: true, + value: { + auth0: { domain: AUTH0_DOMAIN, clientId: AUTH0_CLIENT_ID }, + connections: { google: AUTH0_GOOGLE_CONNECTION }, + adminEmails: [...ADMIN_EMAILS], + }, + updated_at: null, + source: "env", + }); + } + + await ensureConfigTable(); + const r = await pool.query(`select value, updated_at from public.${CONFIG_TABLE} where key = $1`, ["auth"]); + if (!r.rows?.length) return res.status(404).json({ ok: false, error: "not_set" }); + const v = r.rows[0].value || {}; + // legacy: allowedEmails -> adminEmails + if (v && typeof v === "object" && !v.adminEmails && Array.isArray(v.allowedEmails)) { + v.adminEmails = v.allowedEmails; + } + res.json({ ok: true, value: v, updated_at: r.rows[0].updated_at, source: "db" }); + } catch (e) { + res.status(500).json({ ok: false, error: "server_error" }); + } +}); + +// Write auth config (protected by CONFIG_TOKEN) +app.post("/api/config/auth", async (req, res) => { + try { + await ensureConfigTable(); + if (!CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "config_token_not_set" }); + const token = String(req.headers["x-config-token"] || "").trim(); + if (token !== CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "forbidden" }); + + const body = req.body && typeof req.body === "object" ? req.body : {}; + const auth0 = body.auth0 && typeof body.auth0 === "object" ? body.auth0 : {}; + const connections = body.connections && typeof body.connections === "object" ? body.connections : {}; + // legacy: allowedEmails -> adminEmails + const adminEmails = Array.isArray(body.adminEmails) + ? body.adminEmails + : Array.isArray(body.allowedEmails) + ? body.allowedEmails + : []; + + const domain = String(auth0.domain || "").trim(); + const clientId = String(auth0.clientId || "").trim(); + const googleConn = String(connections.google || "").trim(); + const emails = adminEmails.map((x) => String(x).trim().toLowerCase()).filter(Boolean); + + if (!domain || !clientId || !googleConn) { + return res.status(400).json({ ok: false, error: "missing_fields" }); + } + + const value = { + auth0: { domain, clientId }, + connections: { google: googleConn }, + adminEmails: emails, + }; + + await pool.query( + `insert into public.${CONFIG_TABLE} (key, value, updated_at) + values ($1, $2::jsonb, now()) + on conflict (key) do update set value = excluded.value, updated_at = now()`, + ["auth", JSON.stringify(value)] + ); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ ok: false, error: "server_error" }); + } +}); + +// Serve static site +app.use(express.static(__dirname, { extensions: ["html"] })); + +app.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`listening on http://localhost:${PORT}`); +}); + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..1ecb37d --- /dev/null +++ b/styles.css @@ -0,0 +1,846 @@ +:root { + --bg: #0b1020; + --panel: rgba(255, 255, 255, 0.06); + --panel2: rgba(255, 255, 255, 0.09); + --text: rgba(255, 255, 255, 0.92); + --muted: rgba(255, 255, 255, 0.72); + --muted2: rgba(255, 255, 255, 0.58); + --border: rgba(255, 255, 255, 0.12); + --accent: #7c3aed; + --accent2: #22c55e; + --danger: #ef4444; + --shadow: 0 16px 60px rgba(0, 0, 0, 0.45); + --radius: 16px; + --radius2: 12px; + --max: 1120px; + --focus: 0 0 0 3px rgba(124, 58, 237, 0.35); + --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", + "Segoe UI Emoji"; +} + +html[data-theme="light"] { + --bg: #f7f7fb; + --panel: rgba(0, 0, 0, 0.04); + --panel2: rgba(0, 0, 0, 0.06); + --text: rgba(0, 0, 0, 0.9); + --muted: rgba(0, 0, 0, 0.66); + --muted2: rgba(0, 0, 0, 0.52); + --border: rgba(0, 0, 0, 0.12); + --shadow: 0 18px 60px rgba(0, 0, 0, 0.12); + --focus: 0 0 0 3px rgba(124, 58, 237, 0.2); +} + +* { + box-sizing: border-box; +} + +/* Ensure HTML hidden attribute always works (avoid .btn overriding it) */ +[hidden] { + display: none !important; +} + +body { + margin: 0; + font-family: var(--sans); + color: var(--text); + background: radial-gradient(1200px 600px at 20% -10%, rgba(124, 58, 237, 0.35), transparent 60%), + radial-gradient(900px 500px at 90% 10%, rgba(34, 197, 94, 0.22), transparent 55%), + radial-gradient(800px 500px at 40% 110%, rgba(59, 130, 246, 0.16), transparent 60%), var(--bg); + min-height: 100vh; +} + +.wrap { + width: min(var(--max), calc(100% - 32px)); + margin: 0 auto; +} + +.skip-link { + position: absolute; + left: -9999px; + top: auto; + width: 1px; + height: 1px; + overflow: hidden; +} + +.skip-link:focus { + left: 16px; + top: 16px; + width: auto; + height: auto; + padding: 10px 12px; + background: var(--panel2); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow); + outline: none; + z-index: 9999; +} + +.topbar { + position: sticky; + top: 0; + z-index: 100; + backdrop-filter: blur(14px); + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0)); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +html[data-theme="light"] .topbar { + background: linear-gradient(to bottom, rgba(247, 247, 251, 0.92), rgba(247, 247, 251, 0)); + border-bottom: 1px solid rgba(0, 0, 0, 0.06); +} + +.topbar .wrap { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 0; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 200px; +} + +.logo { + width: 40px; + height: 40px; + border-radius: 14px; + background: linear-gradient(135deg, rgba(124, 58, 237, 0.9), rgba(34, 197, 94, 0.7)); + display: grid; + place-items: center; + box-shadow: 0 14px 40px rgba(124, 58, 237, 0.22); +} + +.logo svg { + width: 22px; + height: 22px; + color: rgba(255, 255, 255, 0.92); +} + +.brand-title { + font-weight: 760; + letter-spacing: -0.02em; + font-size: 18px; + line-height: 1.2; +} + +.brand-sub { + margin-top: 2px; + font-size: 12px; + color: var(--muted2); +} + +.actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.divider { + width: 1px; + height: 34px; + align-self: center; + background: var(--border); + border-radius: 999px; + margin: 0 2px; +} + +.sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +.btn.icon-only { + width: 40px; + height: 40px; + padding: 0; + justify-content: center; + gap: 0; +} + +.btn.icon-only svg { + width: 18px; + height: 18px; +} + +.btn.provider-google { + border-color: rgba(66, 133, 244, 0.25); +} + +.btn.provider-kakao { + border-color: rgba(250, 225, 0, 0.35); +} + +.btn.provider-naver { + border-color: rgba(3, 199, 90, 0.28); +} + +.sns-login { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 10px; + padding: 0; + border-radius: 12px; + border: 0; + background: transparent; +} + +.sns-row { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.sns-label { + font-size: 13px; + font-weight: 800; + letter-spacing: -0.02em; + color: var(--muted2); + white-space: nowrap; +} + +.sns-btn { + width: 36px; + height: 36px; + border-radius: 999px; + border: 1px solid rgba(0, 0, 0, 0.10); + display: grid; + place-items: center; + cursor: pointer; + padding: 0; + user-select: none; + transition: transform 120ms ease, filter 120ms ease; +} + +.sns-btn.is-disabled { + opacity: 0.55; + filter: grayscale(0.2); +} + +.sns-btn:hover { + transform: translateY(-1px); + filter: brightness(1.02); +} + +.sns-btn:active { + transform: translateY(0); +} + +.sns-btn:focus-visible { + outline: none; + box-shadow: var(--focus); +} + +.sns-naver { + background: #03c75a; + border-color: rgba(3, 199, 90, 0.35); +} + +.sns-letter { + font-weight: 900; + font-size: 16px; + color: #fff; + letter-spacing: -0.02em; +} + +.sns-kakao { + background: #fae100; + border-color: rgba(250, 225, 0, 0.5); +} + +.sns-kakao-bubble { + width: 14px; + height: 11px; + background: rgba(0, 0, 0, 0.82); + border-radius: 6px; + position: relative; +} + +.sns-kakao-bubble::after { + content: ""; + position: absolute; + bottom: -4px; + left: 4px; + width: 0; + height: 0; + border: 4px solid transparent; + border-top-color: rgba(0, 0, 0, 0.82); +} + +.sns-google { + background: #ffffff; + border-color: rgba(66, 133, 244, 0.4); +} + +.sns-google-g { + font-weight: 900; + font-size: 16px; + background: conic-gradient(from 210deg, #ea4335 0 25%, #fbbc05 25% 50%, #34a853 50% 75%, #4285f4 75% 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + letter-spacing: -0.04em; +} + +.user { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.03); + color: var(--muted); + font-size: 12px; + user-select: none; + max-width: 280px; +} + +.user-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.35); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.08); +} + +html[data-theme="light"] .user-dot { + background: rgba(0, 0, 0, 0.38); + box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.06); +} + +.user[data-auth="ok"] { + color: rgba(180, 255, 210, 0.9); + border-color: rgba(34, 197, 94, 0.28); + background: rgba(34, 197, 94, 0.06); +} + +html[data-theme="light"] .user[data-auth="ok"] { + color: rgba(0, 120, 70, 0.92); +} + +.user[data-auth="ok"] .user-dot { + background: rgba(34, 197, 94, 0.9); + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); +} + +.user-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.btn { + appearance: none; + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + padding: 10px 12px; + border-radius: 12px; + cursor: pointer; + transition: transform 120ms ease, background 120ms ease, border-color 120ms ease; + font-size: 13px; + line-height: 1; + display: inline-flex; + align-items: center; + gap: 8px; + user-select: none; +} + +.btn[disabled], +.icon-btn[disabled] { + opacity: 0.55; + cursor: not-allowed; + transform: none !important; +} + +.btn[disabled]:hover, +.icon-btn[disabled]:hover { + background: var(--panel); +} + +.btn:hover { + background: var(--panel2); + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(0); +} + +.btn:focus-visible { + outline: none; + box-shadow: var(--focus); +} + +.btn-ico { + width: 18px; + height: 18px; + display: inline-grid; + place-items: center; + font-weight: 900; + border-radius: 6px; + background: rgba(124, 58, 237, 0.22); + color: rgba(255, 255, 255, 0.92); +} + +html[data-theme="light"] .btn-ico { + color: rgba(0, 0, 0, 0.82); +} + +.btn-primary { + background: linear-gradient(135deg, rgba(124, 58, 237, 0.92), rgba(99, 102, 241, 0.86)); + border-color: rgba(124, 58, 237, 0.35); +} + +.btn-primary:hover { + background: linear-gradient(135deg, rgba(124, 58, 237, 0.98), rgba(99, 102, 241, 0.92)); +} + +.btn-ghost { + background: transparent; +} + +main { + padding: 18px 0 48px; +} + +.panel { + border: 1px solid var(--border); + background: var(--panel); + border-radius: var(--radius); + padding: 14px; + box-shadow: var(--shadow); +} + +.controls { + display: grid; + grid-template-columns: 1.3fr 0.6fr auto auto; + gap: 12px; + align-items: end; +} + +@media (max-width: 880px) { + .controls { + grid-template-columns: 1fr 1fr; + align-items: center; + } + .meta { + grid-column: 1 / -1; + justify-self: start; + } +} + +@media (max-width: 520px) { + .topbar .wrap { + align-items: flex-start; + } + .brand { + min-width: 0; + } + .controls { + grid-template-columns: 1fr; + } +} + +.field { + display: grid; + gap: 6px; +} + +.field-label { + font-size: 12px; + color: var(--muted2); +} + +.input, +.select { + width: 100%; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + color: var(--text); + border-radius: 12px; + padding: 10px 12px; + font-size: 13px; + outline: none; +} + +html[data-theme="light"] .input, +html[data-theme="light"] .select { + background: rgba(255, 255, 255, 0.75); +} + +.input:focus, +.select:focus { + box-shadow: var(--focus); + border-color: rgba(124, 58, 237, 0.55); +} + +.hint { + margin-top: 6px; + font-size: 12px; + color: var(--muted2); +} + +.check { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 13px; + user-select: none; + padding: 10px 10px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.03); + border-radius: 12px; +} + +.check input { + width: 16px; + height: 16px; +} + +.meta { + justify-self: end; + color: var(--muted2); + font-size: 12px; +} + +.grid { + margin-top: 14px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +@media (max-width: 1020px) { + .grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 640px) { + .grid { + grid-template-columns: 1fr; + } +} + +.card { + position: relative; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + border-radius: var(--radius); + padding: 14px; + overflow: hidden; + box-shadow: 0 10px 34px rgba(0, 0, 0, 0.22); + transition: transform 140ms ease, background 140ms ease, border-color 140ms ease; +} + +.card:hover { + transform: translateY(-2px); + background: rgba(255, 255, 255, 0.06); +} + +.card:focus-within { + box-shadow: var(--shadow), var(--focus); +} + +.card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.card-title { + display: flex; + gap: 12px; + align-items: center; + min-width: 0; +} + +.favicon { + width: 40px; + height: 40px; + border-radius: 14px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.06); + display: grid; + place-items: center; + overflow: hidden; + flex: 0 0 auto; +} + +.favicon img { + width: 22px; + height: 22px; +} + +.favicon .letter { + font-weight: 850; + letter-spacing: -0.02em; + font-size: 14px; + color: rgba(255, 255, 255, 0.9); +} + +html[data-theme="light"] .favicon .letter { + color: rgba(0, 0, 0, 0.78); +} + +.title-wrap { + min-width: 0; +} + +.title { + font-weight: 760; + letter-spacing: -0.02em; + font-size: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.domain { + margin-top: 2px; + font-size: 12px; + font-family: var(--mono); + color: var(--muted2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-desc { + margin-top: 10px; + color: var(--muted); + font-size: 13px; + line-height: 1.45; + min-height: 18px; +} + +.tags { + margin-top: 10px; + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.tag { + font-size: 12px; + padding: 6px 9px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + background: rgba(255, 255, 255, 0.03); +} + +.tag.fav { + border-color: rgba(34, 197, 94, 0.35); + background: rgba(34, 197, 94, 0.08); + color: rgba(180, 255, 210, 0.9); +} + + .tag.lock { + border-color: rgba(239, 68, 68, 0.32); + background: rgba(239, 68, 68, 0.08); + color: rgba(255, 200, 200, 0.92); +} + +html[data-theme="light"] .tag.fav { + color: rgba(0, 120, 70, 0.92); +} + +html[data-theme="light"] .tag.lock { + color: rgba(140, 20, 20, 0.9); +} + +.card-actions { + margin-top: 12px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.card.disabled { + opacity: 0.78; +} + +.card.disabled:hover { + transform: none; +} + +.mini { + padding: 9px 10px; + border-radius: 12px; + font-size: 12px; +} + +.mini-danger { + border-color: rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.08); + color: rgba(255, 200, 200, 0.92); +} + +html[data-theme="light"] .mini-danger { + color: rgba(140, 20, 20, 0.9); +} + +.icon-btn { + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + color: var(--text); + width: 36px; + height: 36px; + border-radius: 12px; + display: grid; + place-items: center; + cursor: pointer; + user-select: none; +} + +.icon-btn:hover { + background: rgba(255, 255, 255, 0.07); +} + +.icon-btn:focus-visible { + outline: none; + box-shadow: var(--focus); +} + +.star { + color: rgba(255, 255, 255, 0.75); +} + +.star.on { + color: #fbbf24; +} + +.empty { + margin-top: 14px; + border: 1px dashed var(--border); + background: rgba(255, 255, 255, 0.03); + border-radius: var(--radius); + padding: 28px 16px; + text-align: center; + color: var(--muted); +} + +.empty-title { + font-weight: 760; + color: var(--text); + margin-bottom: 6px; +} + +.empty-sub { + color: var(--muted2); + font-size: 13px; +} + +.modal[hidden], +.toast[hidden] { + display: none !important; +} + +.modal { + position: fixed; + inset: 0; + z-index: 200; + display: grid; + place-items: center; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.52); + backdrop-filter: blur(4px); +} + +.modal-card { + position: relative; + width: min(560px, calc(100% - 32px)); + border: 1px solid var(--border); + background: rgba(15, 18, 33, 0.92); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; +} + +html[data-theme="light"] .modal-card { + background: rgba(255, 255, 255, 0.92); +} + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 14px 10px; + border-bottom: 1px solid var(--border); +} + +.modal-title { + font-weight: 800; + letter-spacing: -0.02em; +} + +.modal-body { + padding: 14px; + display: grid; + gap: 12px; +} + +.modal-foot { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 6px; +} + +.toast { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 300; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.72); + color: rgba(255, 255, 255, 0.92); + border-radius: 14px; + padding: 12px 14px; + box-shadow: var(--shadow); + max-width: min(520px, calc(100% - 32px)); + font-size: 13px; + line-height: 1.4; +} + +html[data-theme="light"] .toast { + background: rgba(255, 255, 255, 0.92); + color: rgba(0, 0, 0, 0.86); +} + +.noscript { + position: fixed; + left: 16px; + bottom: 16px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--panel2); + padding: 12px 14px; + color: var(--muted); + box-shadow: var(--shadow); +} +