Initial commit after re-install

This commit is contained in:
2026-02-25 19:13:29 +09:00
commit 19c6814d2f
18 changed files with 5879 additions and 0 deletions

View File

@@ -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`
- 예외
- 원격 푸시 실패(권한/네트워크 등) 시: 실패 원인만 짧게 보고하고, 추가 질문 없이 재시도하지 않는다.
- 파괴적 명령(강제 푸시 등)은 절대 사용하지 않는다.

24
.env.example Normal file
View File

@@ -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=

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.DS_Store
node_modules/
.env
.env.*
!.env.example
# Python
.venv/
__pycache__/
*.pyc

21
PROMPT.txt Normal file
View File

@@ -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 등으로 쉽게 구성해주세요.
여기에 로그인 기능 추가가 가능할까요?
구글 로그인, 카카오 로그인, 네이버 로그인 모두 좋습니다.

242
README.md Normal file
View File

@@ -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
<VirtualHost *:80>
ServerName ncue.net
DocumentRoot /path/to/home
<Directory /path/to/home>
Require all granted
</Directory>
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
</VirtualHost>
```
적용:
```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:[...]}`)을 다시 불러옵니다.

1
ads.txt Normal file
View File

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

41
db/schema.sql Normal file
View File

@@ -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()
);

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

591
flask_app.py Normal file
View File

@@ -0,0 +1,591 @@
from __future__ import annotations
import json
import os
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
import jwt
import psycopg2
import psycopg2.pool
import requests
from dotenv import load_dotenv
from flask import Flask, Response, jsonify, redirect, request, send_from_directory
from flask_cors import CORS
load_dotenv()
def env(name: str, default: str = "") -> str:
return str(os.getenv(name, default))
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
def safe_ident(s: str) -> str:
v = str(s or "").strip()
if not _IDENT_RE.match(v):
raise RuntimeError("Invalid TABLE identifier")
return v
def parse_csv(s: str) -> list[str]:
return [x.strip() for x in str(s or "").split(",") if x.strip()]
def parse_email_csv(s: str) -> list[str]:
return [x.lower() for x in parse_csv(s)]
PORT = int(env("PORT", "8023") or "8023")
DB_HOST = env("DB_HOST", "").strip()
DB_PORT = int(env("DB_PORT", "5432") or "5432")
DB_NAME = env("DB_NAME", "").strip()
DB_USER = env("DB_USER", "").strip()
DB_PASSWORD = env("DB_PASSWORD", "").strip()
DB_SSLMODE = env("DB_SSLMODE", "prefer").strip() or "prefer"
DB_CONNECT_TIMEOUT = int(env("DB_CONNECT_TIMEOUT", "5") or "5")
TABLE = safe_ident(env("TABLE", "ncue_user") or "ncue_user")
CONFIG_TABLE = "ncue_app_config"
CONFIG_TOKEN = env("CONFIG_TOKEN", "").strip()
ADMIN_EMAILS = set(parse_email_csv(env("ADMIN_EMAILS", "dosangyoon@gmail.com,dsyoon@ncue.net")))
# Auth0 config via .env (preferred)
AUTH0_DOMAIN = env("AUTH0_DOMAIN", "").strip()
AUTH0_CLIENT_ID = env("AUTH0_CLIENT_ID", "").strip()
AUTH0_GOOGLE_CONNECTION = env("AUTH0_GOOGLE_CONNECTION", "").strip()
# Optional CORS (for static on different origin)
CORS_ORIGINS = env("CORS_ORIGINS", "*").strip() or "*"
_POOL: Optional[psycopg2.pool.SimpleConnectionPool] = None
def db_configured() -> bool:
return bool(DB_HOST and DB_NAME and DB_USER and DB_PASSWORD)
def get_pool() -> psycopg2.pool.SimpleConnectionPool:
"""
Lazy DB pool creation.
- prevents app import failure (which causes Apache 503) when DB is temporarily unavailable
- /api/config/auth can still work purely from .env without DB
"""
global _POOL
if _POOL is not None:
return _POOL
if not db_configured():
raise RuntimeError("db_not_configured")
_POOL = psycopg2.pool.SimpleConnectionPool(
minconn=1,
maxconn=10,
host=DB_HOST,
port=DB_PORT,
dbname=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
sslmode=DB_SSLMODE,
connect_timeout=DB_CONNECT_TIMEOUT,
)
return _POOL
def db_exec(sql: str, params: Tuple[Any, ...] = ()) -> None:
pool = get_pool()
conn = pool.getconn()
try:
conn.autocommit = True
with conn.cursor() as cur:
cur.execute(sql, params)
finally:
pool.putconn(conn)
def db_one(sql: str, params: Tuple[Any, ...] = ()) -> Optional[tuple]:
pool = get_pool()
conn = pool.getconn()
try:
conn.autocommit = True
with conn.cursor() as cur:
cur.execute(sql, params)
row = cur.fetchone()
return row
finally:
pool.putconn(conn)
def ensure_user_table() -> None:
db_exec(
f"""
create table if not exists public.{TABLE} (
sub text primary key,
email text,
name text,
picture text,
provider text,
first_login_at timestamptz,
last_login_at timestamptz,
last_logout_at timestamptz,
can_manage boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
)
"""
)
db_exec(f"create index if not exists idx_{TABLE}_email on public.{TABLE} (email)")
db_exec(f"alter table public.{TABLE} add column if not exists first_login_at timestamptz")
db_exec(f"alter table public.{TABLE} add column if not exists last_logout_at timestamptz")
def ensure_config_table() -> None:
db_exec(
f"""
create table if not exists public.{CONFIG_TABLE} (
key text primary key,
value jsonb not null,
updated_at timestamptz not null default now()
)
"""
)
def is_admin_email(email: str) -> bool:
e = str(email or "").strip().lower()
return e in ADMIN_EMAILS
def bearer_token() -> str:
h = request.headers.get("Authorization", "")
m = re.match(r"^Bearer\s+(.+)$", h, flags=re.IGNORECASE)
return m.group(1).strip() if m else ""
def verify_admin_from_request() -> Tuple[bool, str]:
"""
Returns (is_admin, email_lowercase).
Uses the same headers as /api/auth/sync:
- Authorization: Bearer <id_token>
- X-Auth0-Issuer
- X-Auth0-ClientId
"""
id_token = bearer_token()
if not id_token:
return (False, "")
issuer = str(request.headers.get("X-Auth0-Issuer", "")).strip()
audience = str(request.headers.get("X-Auth0-ClientId", "")).strip()
if not issuer or not audience:
return (False, "")
payload = verify_id_token(id_token, issuer=issuer, audience=audience)
email = (str(payload.get("email")).strip().lower() if payload.get("email") else "")
return (bool(email and is_admin_email(email)), email)
def safe_write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp.replace(path)
@dataclass(frozen=True)
class JwksCacheEntry:
jwks_url: str
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("/<path:filename>")
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)

1108
index.html Normal file

File diff suppressed because it is too large Load Diff

133
links.json Normal file
View File

@@ -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"
}
]

1007
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -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"
}
}

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
Flask
python-dotenv
psycopg2-binary
PyJWT[crypto]
requests
flask-cors

3
run.sh Executable file
View File

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

1514
script.js Normal file

File diff suppressed because it is too large Load Diff

292
server.js Normal file
View File

@@ -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}`);
});

846
styles.css Normal file
View File

@@ -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);
}