From 19c6814d2f2556d1e4c31f0d5764e6cb50a01fa9 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Wed, 25 Feb 2026 19:13:29 +0900 Subject: [PATCH] Initial commit after re-install --- .cursor/rules/auto-commit-push.mdc | 20 + .env.example | 24 + .gitignore | 11 + PROMPT.txt | 21 + README.md | 242 +++++ ads.txt | 1 + db/schema.sql | 41 + favicon.ico | Bin 0 -> 238718 bytes flask_app.py | 591 +++++++++++ index.html | 1108 ++++++++++++++++++++ links.json | 133 +++ package-lock.json | 1007 ++++++++++++++++++ package.json | 18 + requirements.txt | 7 + run.sh | 3 + script.js | 1514 ++++++++++++++++++++++++++++ server.js | 292 ++++++ styles.css | 846 ++++++++++++++++ 18 files changed, 5879 insertions(+) create mode 100644 .cursor/rules/auto-commit-push.mdc create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 PROMPT.txt create mode 100644 README.md create mode 100644 ads.txt create mode 100644 db/schema.sql create mode 100644 favicon.ico create mode 100644 flask_app.py create mode 100644 index.html create mode 100644 links.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 requirements.txt create mode 100755 run.sh create mode 100644 script.js create mode 100644 server.js create mode 100644 styles.css 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 0000000000000000000000000000000000000000..781c16cd1e3d2fe41c203dba1b244f0a796c74ca GIT binary patch literal 238718 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 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); +} +