Compare commits

..

10 Commits

Author SHA1 Message Date
c2d7dffc66 chore(env): .env 갱신(DB/MR 설정 등)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 16:48:55 +09:00
8140b1f6cd refactor(env): user 테이블명을 PG_TABLE로 통일(TABLE 레거시 호환 유지)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 15:24:42 +09:00
94453ccdae refactor(env): PostgreSQL 설정을 PG_DB_* 변수로 통일(DB_* 레거시 호환 유지)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 15:23:17 +09:00
dosangyoon
899cdf14d0 feat(auth): expose ADMIN_EMAILS via /api/config/auth and grant SPA admin when email matches
Made-with: Cursor
2026-03-23 10:31:15 +09:00
dosangyoon
ea104aef6e fix(auth): honor ADMIN_EMAILS with robust email merge and JWT bootstrap for admin checks
Made-with: Cursor
2026-03-23 10:27:59 +09:00
dosangyoon
aff4b7d961 fix(auth): refresh Auth0 config from server every load to clear stale localStorage domain
Made-with: Cursor
2026-03-23 10:21:09 +09:00
dosangyoon
1a41887e62 init 2026-03-23 10:17:12 +09:00
dsyoon
5ea6d6e5b0 init 2026-02-25 21:20:33 +09:00
dsyoon
86d9cdc894 init 2026-02-25 21:10:12 +09:00
dsyoon
c21a7b3739 DB 기반 관리자 권한으로 전환
- ncue_user에 is_admin 추가 및 can_manage 호환 유지
- /api/auth/sync 및 관리 API를 DB is_admin 기반으로 변경
- index.html 폴백에서 관리자 이메일 하드코딩 제거
- .env로 관리자 부트스트랩(ADMIN_EMAILS) 지원

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 21:09:02 +09:00
9 changed files with 450 additions and 182 deletions

31
.env Normal file
View File

@@ -0,0 +1,31 @@
## Database (PostgreSQL)
PG_DB_HOST=ncue.net
PG_DB_PORT=5432
PG_DB_NAME=ncue
PG_DB_USER=ncue
PG_DB_PASSWORD=ncue5004!
PG_TABLE=ncue_user
## Auth0 — Dashboard 앱 "NCue" (Applications → NCue)
# 테넌트 Domain만 (https 없음). 반드시 *.auth0.com — 사이트 도메인(ncue.net) 넣지 말 것.
AUTH0_DOMAIN=dev-4la1w2h67ybqsbpe.us.auth0.com
# Client ID (SPA)
AUTH0_CLIENT_ID=g5RDfax7FZkKzXYvXOgku3Ll8CxuA4IM
# Google 연결 이름 (Connections에서 확인, 보통 google-oauth2)
AUTH0_GOOGLE_CONNECTION=google-oauth2
# Client Secret: 이 백엔드는 PKCE(SPA)만 쓰므로 .env에 불필요
## Admin bootstrap (server-side only)
# Comma-separated emails that should become admin (DB: ncue_user.is_admin=true) on login sync.
ADMIN_EMAILS=dosangyoon@gmail.com,dosangyoon2@gmail.com,dosangyoon3@gmail.com
## Optional
# Server port
PORT=8000
# Protect admin/config write APIs
CONFIG_TOKEN=REPLACE_ME
AUTH0_DASHBOARD_TOKEN=DTURPQF9VAF7UCZED2GEYSZB

View File

@@ -1,24 +0,0 @@
## 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=

3
.gitignore vendored
View File

@@ -1,8 +1,5 @@
.DS_Store
node_modules/
.env
.env.*
!.env.example
# Python
.venv/

View File

@@ -33,9 +33,11 @@ python3 -m http.server 8000
### 1) DB 테이블 생성(서버에서 1회)
```bash
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f db/schema.sql
psql -h "$PG_DB_HOST" -p "$PG_DB_PORT" -U "$PG_DB_USER" -d "$PG_DB_NAME" -f db/schema.sql
```
`.env`의 PostgreSQL 변수는 **`PG_DB_HOST`**, **`PG_DB_PORT`**, **`PG_DB_NAME`**, **`PG_DB_USER`**, **`PG_DB_PASSWORD`** 입니다. (선택: `PG_DB_SSLMODE`, `PG_DB_CONNECT_TIMEOUT`.) 사용자 upsert 대상 테이블 이름은 **`PG_TABLE`**(기본 `ncue_user`). 배포 마이그레이션 동안 레거시 `DB_*` / `TABLE` 이름도 한동안 읽힐 수 있습니다.
### 2) 실행 방법 A: (로컬/간단) venv로 실행
```bash
@@ -95,8 +97,8 @@ RestartSec=3
EnvironmentFile=/path/to/home/.env
# (선택) DB 연결 옵션(문제 발생 시 조정)
Environment=DB_SSLMODE=prefer
Environment=DB_CONNECT_TIMEOUT=5
Environment=PG_DB_SSLMODE=prefer
Environment=PG_DB_CONNECT_TIMEOUT=5
[Install]
WantedBy=multi-user.target
@@ -181,7 +183,8 @@ sudo journalctl -u ncue-flask -n 200 --no-pager
> 참고: `flask_app.py`는 DB가 일시적으로 죽어도 앱 임포트 단계에서 바로 죽지 않도록(DB pool lazy 생성) 개선되어,
> “백엔드 프로세스가 안 떠서 Apache가 503”인 케이스를 줄였습니다.
기본적으로 `.env``ADMIN_EMAILS`에 포함된 이메일은 `can_manage=true`자동 승격됩니다.
관리 권한은 DB 사용자 테이블(`ncue_user`)`is_admin=true`부여됩니다.
(`can_manage`는 호환을 위해 남겨두며, 서버는 `is_admin`을 기준으로 동작합니다.)
### (선택) 역프록시/분리 배포
@@ -189,10 +192,24 @@ sudo journalctl -u ncue-flask -n 200 --no-pager
- 정적은 별도 호스팅 + API만 Flask: `index.html``window.AUTH_CONFIG.apiBase`에 API 주소를 넣고,
Flask에서는 `CORS_ORIGINS`로 허용 도메인을 지정하세요.
최초 로그인 사용자는 DB에 저장되지만 `can_manage=false`입니다. 관리 권한을 주려면:
최초 로그인 사용자는 DB에 저장되며 기본값은 `is_admin=false`입니다. 관리자 승격 방법:
- (권장) 서버 `.env``ADMIN_EMAILS`를 설정하면, 해당 이메일 사용자가 로그인할 때 `/api/auth/sync`에서 자동으로
`is_admin=true`로 승격됩니다. (프론트로 노출되지 않음)
- API(서버 `.env``CONFIG_TOKEN` 설정 후):
```bash
curl -sS -X POST http://127.0.0.1:8023/api/admin/users/set_admin \
-H "Content-Type: application/json" \
-H "X-Config-Token: $CONFIG_TOKEN" \
-d '{"email":"me@example.com","isAdmin":true}'
```
- 또는 SQL로 직접:
```sql
update ncue_user set can_manage = true where email = 'me@example.com';
update ncue_user set is_admin = true, can_manage = true where email = 'me@example.com';
```
## 로그인(관리 기능 잠금)
@@ -209,7 +226,7 @@ update ncue_user set can_manage = true where email = 'me@example.com';
- `AUTH0_DOMAIN`
- `AUTH0_CLIENT_ID`
- `AUTH0_GOOGLE_CONNECTION` (예: `google-oauth2`)
- `ADMIN_EMAILS` (예: `dosangyoon@gmail.com,dsyoon@ncue.net`)
- (선택) `CONFIG_TOKEN` (관리자 승격 API 보호용)
3. Auth0 Application 설정에서 아래 URL들을 등록
- Allowed Callback URLs: `https://ncue.net/``https://ncue.net`
- Allowed Logout URLs: `https://ncue.net/``https://ncue.net`

View File

@@ -1,5 +1,5 @@
-- 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
-- Run: psql -h "$PG_DB_HOST" -p "$PG_DB_PORT" -U "$PG_DB_USER" -d "$PG_DB_NAME" -f db/schema.sql
DO $$
BEGIN
@@ -18,6 +18,7 @@ BEGIN
first_login_at timestamptz,
last_login_at timestamptz,
last_logout_at timestamptz,
is_admin boolean NOT NULL DEFAULT false,
can_manage boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
@@ -30,6 +31,12 @@ 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;
ALTER TABLE public.ncue_user ADD COLUMN IF NOT EXISTS is_admin boolean NOT NULL DEFAULT false;
-- Backward-compat: previously can_manage was used as admin flag. Preserve existing admins.
UPDATE public.ncue_user SET is_admin = true WHERE can_manage = true AND is_admin = false;
-- Keep can_manage consistent with is_admin going forward.
UPDATE public.ncue_user SET can_manage = is_admin WHERE can_manage IS DISTINCT FROM is_admin;
-- App config (shared across browsers)
CREATE TABLE IF NOT EXISTS public.ncue_app_config (

View File

@@ -43,21 +43,47 @@ def parse_email_csv(s: str) -> list[str]:
return [x.lower() for x in parse_csv(s)]
def email_from_id_token_payload(payload: dict) -> Optional[str]:
"""Prefer standard `email`; also accept Auth0-style namespaced claims ending with `/email`."""
v = payload.get("email")
if isinstance(v, str) and v.strip():
return v.strip().lower()
for k, val in payload.items():
if isinstance(k, str) and k.endswith("/email") and isinstance(val, str) and val.strip():
return val.strip().lower()
return None
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")
def _env_pg(name_primary: str, name_legacy: str, default: str = "") -> str:
"""Read PG_DB_* ; fall back to legacy DB_* for one-off migrations."""
v = env(name_primary, "").strip()
if v:
return v
return str(env(name_legacy, default) or "").strip()
TABLE = safe_ident(env("TABLE", "ncue_user") or "ncue_user")
def _env_pg_table() -> str:
"""User table identifier: PG_TABLE, else legacy TABLE, default ncue_user."""
v = env("PG_TABLE", "").strip()
if v:
return v
return str(env("TABLE", "ncue_user") or "").strip() or "ncue_user"
DB_HOST = _env_pg("PG_DB_HOST", "DB_HOST").strip()
DB_PORT = int(_env_pg("PG_DB_PORT", "DB_PORT", "5432") or "5432")
DB_NAME = _env_pg("PG_DB_NAME", "DB_NAME").strip()
DB_USER = _env_pg("PG_DB_USER", "DB_USER").strip()
DB_PASSWORD = _env_pg("PG_DB_PASSWORD", "DB_PASSWORD").strip()
DB_SSLMODE = _env_pg("PG_DB_SSLMODE", "DB_SSLMODE", "prefer").strip() or "prefer"
DB_CONNECT_TIMEOUT = int(_env_pg("PG_DB_CONNECT_TIMEOUT", "DB_CONNECT_TIMEOUT", "5") or "5")
TABLE = safe_ident(_env_pg_table())
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")))
ADMIN_EMAILS = set(parse_email_csv(env("ADMIN_EMAILS", "")))
# Auth0 config via .env (preferred)
AUTH0_DOMAIN = env("AUTH0_DOMAIN", "").strip()
@@ -135,6 +161,7 @@ def ensure_user_table() -> None:
first_login_at timestamptz,
last_login_at timestamptz,
last_logout_at timestamptz,
is_admin boolean not null default false,
can_manage boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
@@ -144,6 +171,11 @@ def ensure_user_table() -> None:
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")
db_exec(f"alter table public.{TABLE} add column if not exists is_admin boolean not null default false")
# Backward-compat: previously can_manage was used as admin flag. Preserve existing admins.
db_exec(f"update public.{TABLE} set is_admin = true where can_manage = true and is_admin = false")
# Keep can_manage consistent with is_admin going forward.
db_exec(f"update public.{TABLE} set can_manage = is_admin where can_manage is distinct from is_admin")
def ensure_config_table() -> None:
@@ -158,20 +190,20 @@ def ensure_config_table() -> None:
)
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]:
def is_bootstrap_admin(email: Optional[str]) -> bool:
e = str(email or "").strip().lower()
return bool(e and e in ADMIN_EMAILS)
def verify_user_from_request() -> Tuple[Optional[str], str]:
"""
Returns (is_admin, email_lowercase).
Returns (sub, email_lowercase).
Uses the same headers as /api/auth/sync:
- Authorization: Bearer <id_token>
- X-Auth0-Issuer
@@ -179,16 +211,34 @@ def verify_admin_from_request() -> Tuple[bool, str]:
"""
id_token = bearer_token()
if not id_token:
return (False, "")
return (None, "")
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, "")
return (None, "")
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)
sub = str(payload.get("sub") or "").strip()
e = email_from_id_token_payload(payload)
email = e or ""
return (sub or None, email)
def is_request_admin() -> Tuple[bool, str]:
"""
Returns (is_admin, email_lowercase) using DB-stored flag.
"""
if not db_configured():
return (False, "")
ensure_user_table()
sub, email = verify_user_from_request()
if email and is_bootstrap_admin(email):
return (True, email)
if not sub:
return (False, email)
row = db_one(f"select is_admin from public.{TABLE} where sub = %s", (sub,))
return (bool(row and row[0] is True), email)
def safe_write_json(path: Path, data: Any) -> None:
@@ -384,45 +434,55 @@ def api_auth_sync() -> Response:
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)
jwt_email = email_from_id_token_payload(payload)
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
prev_row = db_one(f"select email from public.{TABLE} where sub = %s", (sub,))
prev_email: Optional[str] = None
if prev_row and prev_row[0]:
pe = str(prev_row[0]).strip().lower()
prev_email = pe or None
effective_email = jwt_email or prev_email
bootstrap_admin = bool(effective_email and is_bootstrap_admin(effective_email))
sql = f"""
insert into public.{TABLE}
(sub, email, name, picture, provider, first_login_at, last_login_at, can_manage, updated_at)
(sub, email, name, picture, provider, first_login_at, last_login_at, is_admin, can_manage, updated_at)
values
(%s, %s, %s, %s, %s, now(), now(), %s, now())
(%s, %s, %s, %s, %s, now(), now(), %s, %s, now())
on conflict (sub) do update set
email = excluded.email,
email = coalesce(nullif(btrim(excluded.email::text), ''), public.{TABLE}.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),
is_admin = (public.{TABLE}.is_admin or public.{TABLE}.can_manage or %s),
can_manage = (public.{TABLE}.is_admin or public.{TABLE}.can_manage or %s),
updated_at = now()
returning can_manage, first_login_at, last_login_at, last_logout_at
returning is_admin, 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
row = db_one(sql, (sub, jwt_email, name, picture, provider, bootstrap_admin, bootstrap_admin, bootstrap_admin, bootstrap_admin))
is_admin = bool(row[0]) if row else False
can_manage = bool(row[1]) if row else False
user = (
{
"is_admin": is_admin,
"can_manage": can_manage,
"first_login_at": row[1],
"last_login_at": row[2],
"last_logout_at": row[3],
"first_login_at": row[2],
"last_login_at": row[3],
"last_logout_at": row[4],
}
if row
else None
)
return jsonify({"ok": True, "canManage": can_manage, "user": user})
return jsonify({"ok": True, "canManage": is_admin, "user": user})
except Exception:
return jsonify({"ok": False, "error": "verify_failed"}), 401
@@ -471,7 +531,8 @@ def api_config_auth_get() -> Response:
"value": {
"auth0": {"domain": AUTH0_DOMAIN, "clientId": AUTH0_CLIENT_ID},
"connections": {"google": AUTH0_GOOGLE_CONNECTION},
"adminEmails": sorted(list(ADMIN_EMAILS)),
# Mirrors .env ADMIN_EMAILS for SPA: unlock UI when /api/auth/sync lags or misreads .env
"adminEmails": sorted(ADMIN_EMAILS),
},
"updated_at": None,
"source": "env",
@@ -489,9 +550,22 @@ def api_config_auth_get() -> Response:
if isinstance(value, str):
value = json.loads(value)
if isinstance(value, dict) and "adminEmails" not in value and isinstance(value.get("allowedEmails"), list):
if not isinstance(value, dict):
return jsonify({"ok": False, "error": "not_set"}), 404
if "adminEmails" not in value and isinstance(value.get("allowedEmails"), list):
value["adminEmails"] = value.get("allowedEmails")
merged: set[str] = set()
ae = value.get("adminEmails")
if isinstance(ae, list):
for x in ae:
if isinstance(x, str) and x.strip():
merged.add(x.strip().lower())
merged |= ADMIN_EMAILS
value = dict(value)
value["adminEmails"] = sorted(merged)
return jsonify({"ok": True, "value": value, "updated_at": row[1], "source": "db"})
except Exception:
return jsonify({"ok": False, "error": "server_error"}), 500
@@ -525,7 +599,7 @@ def api_links_put() -> Response:
- {"links":[...]}
"""
try:
ok_admin, _email = verify_admin_from_request()
ok_admin, _email = is_request_admin()
if not ok_admin:
return jsonify({"ok": False, "error": "forbidden"}), 403
@@ -585,6 +659,65 @@ def api_config_auth_post() -> Response:
return jsonify({"ok": False, "error": "server_error"}), 500
@app.post("/api/admin/users/set_admin")
def api_admin_users_set_admin() -> Response:
"""
CONFIG_TOKEN-protected API to set per-user admin flag in DB.
Body:
- {"sub":"...", "isAdmin": true}
- or {"email":"user@example.com", "isAdmin": true}
"""
try:
if not db_configured():
return jsonify({"ok": False, "error": "db_not_configured"}), 500
ensure_user_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 {}
sub = str(body.get("sub") or "").strip()
email = str(body.get("email") or "").strip().lower()
is_admin = body.get("isAdmin")
if not isinstance(is_admin, bool):
# accept legacy keys
is_admin = body.get("admin")
if not isinstance(is_admin, bool):
return jsonify({"ok": False, "error": "missing_isAdmin"}), 400
if not sub and not email:
return jsonify({"ok": False, "error": "missing_identifier"}), 400
if sub:
sql = f"""
update public.{TABLE}
set is_admin = %s,
can_manage = %s,
updated_at = now()
where sub = %s
returning sub, email, is_admin
"""
row = db_one(sql, (is_admin, is_admin, sub))
else:
sql = f"""
update public.{TABLE}
set is_admin = %s,
can_manage = %s,
updated_at = now()
where email = %s
returning sub, email, is_admin
"""
row = db_one(sql, (is_admin, is_admin, email))
if not row:
return jsonify({"ok": False, "error": "not_found"}), 404
return jsonify({"ok": True, "user": {"sub": row[0], "email": row[1], "is_admin": bool(row[2])}})
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)

View File

@@ -435,18 +435,11 @@
]);
let sessionEmail = "";
function isAdminEmail(email) {
const cfg = getAuthConfig();
const admins = cfg && Array.isArray(cfg.adminEmails) ? cfg.adminEmails : [];
const e = String(email || "").trim().toLowerCase();
if (admins.length) return admins.includes(e);
// fallback default
return ["dsyoon@ncue.net", "dosangyoon@gmail.com", "dosangyoon2@gmail.com", "dosangyoon3@gmail.com"].includes(e);
}
function canAccessLink(link) {
// Admin access is determined by server (DB: ncue_user.is_admin) via /api/auth/sync.
if (auth && auth.authorized) return true;
const id = String(link && link.id ? link.id : "");
const email = String(sessionEmail || "").trim().toLowerCase();
if (email && isAdminEmail(email)) return true;
if (email) return ACCESS_USER_IDS.has(id);
return ACCESS_ANON_IDS.has(id);
}
@@ -751,6 +744,7 @@
const auth = {
client: null,
user: null,
authorized: false,
ready: false,
};
@@ -759,10 +753,35 @@
return pathname;
}
function buildAuthConfigBase() {
const cfg = window.AUTH_CONFIG && typeof window.AUTH_CONFIG === "object" ? window.AUTH_CONFIG : {};
const auth0 = cfg.auth0 && typeof cfg.auth0 === "object" ? cfg.auth0 : {};
const connections = cfg.connections && typeof cfg.connections === "object" ? cfg.connections : {};
const adminEmails = Array.isArray(cfg.adminEmails)
? cfg.adminEmails
: Array.isArray(cfg.allowedEmails)
? cfg.allowedEmails
: [];
return {
auth0: {
domain: String(auth0.domain || "").trim(),
clientId: String(auth0.clientId || "").trim(),
},
connections: {
google: String(connections.google || "").trim(),
kakao: String(connections.kakao || "").trim(),
naver: String(connections.naver || "").trim(),
},
adminEmails: adminEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
};
}
async function hydrateAuthConfigFromServerIfNeeded() {
const cfg = getAuthConfig();
const hasLocal = Boolean(cfg.auth0.domain && cfg.auth0.clientId && cfg.connections.google);
if (hasLocal) return true;
const embedded = buildAuthConfigBase();
const fullyInPage = Boolean(
embedded.auth0.domain && embedded.auth0.clientId && embedded.connections.google
);
if (fullyInPage) return true;
try {
const r = await fetch(apiUrl("/api/config/auth"), { cache: "no-store" });
if (!r.ok) return false;
@@ -771,7 +790,11 @@
const v = data.value;
const auth0 = v.auth0 || {};
const connections = v.connections || {};
const adminEmails = Array.isArray(v.adminEmails) ? v.adminEmails : Array.isArray(v.allowedEmails) ? v.allowedEmails : [];
const 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();
@@ -781,7 +804,7 @@
JSON.stringify({
auth0: { domain, clientId },
connections: { google },
adminEmails,
adminEmails: adminEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
})
);
return true;
@@ -791,7 +814,7 @@
}
function applyManageLock() {
const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail);
const canManage = Boolean(auth.user) && Boolean(auth.authorized);
if (el.btnAdd) el.btnAdd.disabled = !canManage;
if (el.btnImport) el.btnImport.disabled = !canManage;
// 요청: 로그인 전 내보내기 비활성화
@@ -825,27 +848,7 @@
}
function getAuthConfig() {
const cfg = window.AUTH_CONFIG && typeof window.AUTH_CONFIG === "object" ? window.AUTH_CONFIG : {};
const auth0 = cfg.auth0 && typeof cfg.auth0 === "object" ? cfg.auth0 : {};
const connections = cfg.connections && typeof cfg.connections === "object" ? cfg.connections : {};
// legacy: allowedEmails -> adminEmails
const adminEmails = Array.isArray(cfg.adminEmails)
? cfg.adminEmails
: Array.isArray(cfg.allowedEmails)
? cfg.allowedEmails
: [];
const base = {
auth0: {
domain: String(auth0.domain || "").trim(),
clientId: String(auth0.clientId || "").trim(),
},
connections: {
google: String(connections.google || "").trim(),
kakao: String(connections.kakao || "").trim(),
naver: String(connections.naver || "").trim(),
},
adminEmails: adminEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
};
const base = buildAuthConfigBase();
const over = loadAuthOverride();
if (!over) return base;
return {
@@ -862,6 +865,18 @@
};
}
function isConfigListedAdmin(emailRaw) {
const e = String(emailRaw || "").trim().toLowerCase();
if (!e) return false;
const cfg = getAuthConfig();
const list = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : [];
return list.some((x) => String(x).trim().toLowerCase() === e);
}
function resolveAuthorizedAfterSync(canManageFromServer) {
return Boolean(canManageFromServer) || isConfigListedAdmin(sessionEmail);
}
function currentUrlNoQuery() {
const u = new URL(location.href);
u.searchParams.delete("code");
@@ -919,18 +934,27 @@
const raw = claims && claims.__raw ? String(claims.__raw) : "";
if (raw) {
const cfg = getAuthConfig();
await fetch(apiUrl("/api/auth/sync"), {
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,
},
}).catch(() => {});
}).catch(() => null);
if (r && r.ok) {
const data = await r.json().catch(() => null);
if (data && data.ok) auth.authorized = resolveAuthorizedAfterSync(data.canManage);
} else if (auth.user) {
auth.authorized = isConfigListedAdmin(sessionEmail);
}
}
} catch {
// ignore
}
if (auth.user && !auth.authorized) {
auth.authorized = isConfigListedAdmin(sessionEmail);
}
}
applyManageLock();
@@ -1036,9 +1060,9 @@
e.preventDefault();
return;
}
// block manage actions unless admin
// block manage actions unless admin (server: ncue_user.is_admin)
const act0 = btn.getAttribute("data-act");
const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail);
const canManage = Boolean(auth.user) && Boolean(auth.authorized);
if (!canManage && (act0 === "fav" || act0 === "edit" || act0 === "del")) {
toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다.");
return;
@@ -1066,7 +1090,7 @@
if (el.btnExport) el.btnExport.addEventListener("click", exportJson);
if (el.btnImport)
el.btnImport.addEventListener("click", () => {
const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail);
const canManage = Boolean(auth.user) && Boolean(auth.authorized);
if (!canManage) return toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다.");
if (el.file) el.file.click();
});

View File

@@ -71,29 +71,31 @@
"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();
/** 서버 /api/config/auth 의 adminEmails(.env ADMIN_EMAILS와 동기)에 포함된 로그인 사용자 */
function isConfigListedAdmin(emailRaw) {
const e = String(emailRaw || "").trim().toLowerCase();
if (!e) return false;
const cfg = getAuthConfig();
const admins = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : [];
if (admins.length) return admins.includes(e);
return DEFAULT_ADMIN_EMAILS.has(e);
const list = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : [];
return list.some((x) => String(x).trim().toLowerCase() === e);
}
function resolveAuthorizedAfterSync(canManageFromServer) {
const fromApi = Boolean(canManageFromServer);
const fromList = isConfigListedAdmin(getUserEmail());
return fromApi || fromList;
}
function canAccessLink(link) {
// Admin (DB: ncue_user.is_admin) can access all links.
if (auth && auth.authorized) return true;
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);
@@ -545,42 +547,47 @@
localStorage.removeItem(AUTH_OVERRIDE_KEY);
}
function getAuthConfig() {
/** index.html(window.AUTH_CONFIG)만 반영. localStorage 캐시는 포함하지 않음. */
function buildAuthConfigBase() {
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 apiBase = String(cfg.apiBase || "").trim();
const auth0 = cfg.auth0 && typeof cfg.auth0 === "object" ? cfg.auth0 : {};
// legacy: allowedEmails -> adminEmails
const conn = cfg.connections && typeof cfg.connections === "object" ? cfg.connections : {};
const adminEmails = Array.isArray(cfg.adminEmails)
? cfg.adminEmails
: Array.isArray(cfg.allowedEmails)
? cfg.allowedEmails
: [];
const base = {
return {
apiBase,
auth0: {
domain: String(auth0.domain || "").trim(),
clientId: String(auth0.clientId || "").trim(),
},
connections: {
google: "",
kakao: "",
naver: "",
google: String(conn.google || "").trim(),
kakao: String(conn.kakao || "").trim(),
naver: String(conn.naver || "").trim(),
},
adminEmails: adminEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
};
}
function getAuthConfig() {
const base = buildAuthConfigBase();
const override = loadAuthOverride();
if (!override) return base;
// override가 있으면 우선 적용 (서버 재배포 없이 테스트 가능)
// 서버에서 받아 둔 캐시(또는 예전 잘못된 값). 서버 .env 수정 후에는 hydrate가 덮어씀.
return {
apiBase,
apiBase: base.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 || "",
google: override.connections?.google || base.connections.google,
kakao: override.connections?.kakao || base.connections.kakao,
naver: override.connections?.naver || base.connections.naver,
},
adminEmails: override.adminEmails.length ? override.adminEmails : base.adminEmails,
};
@@ -598,9 +605,13 @@
}
async function hydrateAuthConfigFromServerIfNeeded() {
const cfg = getAuthConfig();
const hasLocal = Boolean(cfg.auth0.domain && cfg.auth0.clientId && cfg.connections.google);
if (hasLocal) return true;
const embedded = buildAuthConfigBase();
const fullyInPage = Boolean(
embedded.auth0.domain && embedded.auth0.clientId && embedded.connections.google
);
// index에 Auth0 전부 박혀 있으면 서버 조회 생략(오프라인/별도 배포용)
if (fullyInPage) return true;
try {
const r = await fetch(apiUrl("/api/config/auth"), { cache: "no-store" });
if (!r.ok) return false;
@@ -609,7 +620,6 @@
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)
@@ -619,6 +629,7 @@
const clientId = String(auth0.clientId || "").trim();
const google = String(connections.google || "").trim();
if (!domain || !clientId || !google) return false;
// 예전 버전: 잘못된 도메인이 localStorage에 남으면 hydrate를 건너뛰어 영구 고착됨 → 매 로드마다 덮어씀
saveAuthOverride({
auth0: { domain, clientId },
connections: { google },
@@ -896,15 +907,6 @@
}
}
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) {
@@ -982,8 +984,7 @@
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.authorized = false;
auth.serverCanManage = null;
const t = loadTokens();
if (auth.user && t && t.id_token) {
@@ -991,7 +992,9 @@
const can = await syncUserToServerWithIdToken(t.id_token);
if (typeof can === "boolean") {
auth.serverCanManage = can;
auth.authorized = can;
auth.authorized = resolveAuthorizedAfterSync(can);
} else if (auth.user) {
auth.authorized = isConfigListedAdmin(getUserEmail());
}
}
updateAuthUi();
@@ -1037,8 +1040,7 @@
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.authorized = false;
auth.serverCanManage = null;
// 로그인되었으면 서버에 사용자 upsert 및 can_manage 동기화(서버가 있을 때만)
@@ -1061,13 +1063,18 @@
const data = await r.json();
if (data && data.ok) {
auth.serverCanManage = Boolean(data.canManage);
auth.authorized = auth.serverCanManage;
auth.authorized = resolveAuthorizedAfterSync(data.canManage);
}
} else if (auth.user) {
auth.authorized = isConfigListedAdmin(getUserEmail());
}
}
} catch {
// ignore: server not running or blocked
}
if (auth.user && !auth.authorized) {
auth.authorized = isConfigListedAdmin(getUserEmail());
}
}
if (auth.user && !auth.authorized) {

128
server.js
View File

@@ -36,16 +36,50 @@ function parseEmailCsv(s) {
return parseCsv(s).map((x) => x.toLowerCase());
}
/** Prefer PG_DB_* env ; fall back to legacy DB_* for migrations. */
function pgEnv(primary, legacy, fallback = "") {
const v = String(env(primary, "")).trim();
if (v) return v;
return String(env(legacy, fallback)).trim();
}
/** Prefer `email`; also Auth0-style namespaced claims ending with `/email`. */
function emailFromIdTokenPayload(payload) {
const e = payload?.email;
if (typeof e === "string" && e.trim()) return e.trim().toLowerCase();
if (!payload || typeof payload !== "object") return null;
for (const k of Object.keys(payload)) {
if (typeof k === "string" && k.endsWith("/email")) {
const v = payload[k];
if (typeof v === "string" && v.trim()) return v.trim().toLowerCase();
}
}
return null;
}
function mustPg(primary, legacy) {
const v = pgEnv(primary, legacy);
if (!v) throw new Error(`Missing env: ${primary}${legacy ? ` or ${legacy}` : ""}`);
return v;
}
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 DB_HOST = mustPg("PG_DB_HOST", "DB_HOST");
const DB_PORT = Number(pgEnv("PG_DB_PORT", "DB_PORT", "5432")) || 5432;
const DB_NAME = mustPg("PG_DB_NAME", "DB_NAME");
const DB_USER = mustPg("PG_DB_USER", "DB_USER");
const DB_PASSWORD = mustPg("PG_DB_PASSWORD", "DB_PASSWORD");
function pgTableName() {
const v = String(env("PG_TABLE", "")).trim();
if (v) return v;
return String(env("TABLE", "ncue_user")).trim() || "ncue_user";
}
const TABLE = safeIdent(pgTableName());
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")));
const ADMIN_EMAILS = new Set(parseEmailCsv(env("ADMIN_EMAILS", "")));
// Auth0 config via .env (preferred)
const AUTH0_DOMAIN = env("AUTH0_DOMAIN", "").trim();
@@ -107,6 +141,7 @@ async function ensureUserTable() {
first_login_at timestamptz,
last_login_at timestamptz,
last_logout_at timestamptz,
is_admin boolean not null default false,
can_manage boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
@@ -115,6 +150,11 @@ async function ensureUserTable() {
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`);
await pool.query(`alter table public.${TABLE} add column if not exists is_admin boolean not null default false`);
// Backward-compat: previously can_manage was used as admin flag. Preserve existing admins.
await pool.query(`update public.${TABLE} set is_admin = true where can_manage = true and is_admin = false`);
// Keep can_manage consistent with is_admin going forward.
await pool.query(`update public.${TABLE} set can_manage = is_admin where can_manage is distinct from is_admin`);
}
async function ensureConfigTable() {
@@ -127,11 +167,6 @@ async function ensureConfigTable() {
`);
}
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();
@@ -145,34 +180,41 @@ app.post("/api/auth/sync", async (req, res) => {
const payload = await verifyIdToken(idToken, { issuer, audience });
const sub = String(payload.sub || "").trim();
const email = payload.email ? String(payload.email).trim().toLowerCase() : null;
const jwtEmail = emailFromIdTokenPayload(payload);
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 pr = await pool.query(`select email from public.${TABLE} where sub = $1`, [sub]);
const rawPrev = pr.rows?.[0]?.email;
const prevEmail =
rawPrev != null && String(rawPrev).trim() ? String(rawPrev).trim().toLowerCase() : null;
const effectiveEmail = jwtEmail || prevEmail;
const bootstrapAdmin = Boolean(effectiveEmail && ADMIN_EMAILS.has(effectiveEmail));
const q = `
insert into public.${TABLE}
(sub, email, name, picture, provider, first_login_at, last_login_at, can_manage, updated_at)
(sub, email, name, picture, provider, first_login_at, last_login_at, is_admin, can_manage, updated_at)
values
($1, $2, $3, $4, $5, now(), now(), $6, now())
($1, $2, $3, $4, $5, now(), now(), $6, $6, now())
on conflict (sub) do update set
email = excluded.email,
email = coalesce(nullif(btrim(excluded.email::text), ''), public.${TABLE}.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),
is_admin = (public.${TABLE}.is_admin or public.${TABLE}.can_manage or $6),
can_manage = (public.${TABLE}.is_admin or public.${TABLE}.can_manage or $6),
updated_at = now()
returning can_manage, first_login_at, last_login_at, last_logout_at
returning is_admin, 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);
const r = await pool.query(q, [sub, jwtEmail, name, picture, provider, bootstrapAdmin]);
const isAdmin = Boolean(r.rows?.[0]?.is_admin);
res.json({ ok: true, canManage, user: r.rows?.[0] || null });
res.json({ ok: true, canManage: isAdmin, user: r.rows?.[0] || null });
} catch (e) {
res.status(401).json({ ok: false, error: "verify_failed" });
}
@@ -216,7 +258,7 @@ app.get("/api/config/auth", async (_req, res) => {
value: {
auth0: { domain: AUTH0_DOMAIN, clientId: AUTH0_CLIENT_ID },
connections: { google: AUTH0_GOOGLE_CONNECTION },
adminEmails: [...ADMIN_EMAILS],
adminEmails: Array.from(ADMIN_EMAILS).sort(),
},
updated_at: null,
source: "env",
@@ -226,11 +268,19 @@ app.get("/api/config/auth", async (_req, res) => {
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 || {};
let v = r.rows[0].value || {};
if (typeof v !== "object" || v === null) v = {};
// legacy: allowedEmails -> adminEmails
if (v && typeof v === "object" && !v.adminEmails && Array.isArray(v.allowedEmails)) {
v.adminEmails = v.allowedEmails;
if (!v.adminEmails && Array.isArray(v.allowedEmails)) v = { ...v, adminEmails: v.allowedEmails };
const merged = new Set(ADMIN_EMAILS);
const ae = v.adminEmails;
if (Array.isArray(ae)) {
for (const x of ae) {
const s = String(x || "").trim().toLowerCase();
if (s) merged.add(s);
}
}
v = { ...v, adminEmails: Array.from(merged).sort() };
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" });
@@ -282,6 +332,32 @@ app.post("/api/config/auth", async (req, res) => {
}
});
// CONFIG_TOKEN-protected: set per-user admin flag in DB
app.post("/api/admin/users/set_admin", async (req, res) => {
try {
await ensureUserTable();
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 sub = String(body.sub || "").trim();
const email = String(body.email || "").trim().toLowerCase();
const isAdmin = typeof body.isAdmin === "boolean" ? body.isAdmin : typeof body.admin === "boolean" ? body.admin : null;
if (typeof isAdmin !== "boolean") return res.status(400).json({ ok: false, error: "missing_isAdmin" });
if (!sub && !email) return res.status(400).json({ ok: false, error: "missing_identifier" });
const q = sub
? `update public.${TABLE} set is_admin = $1, can_manage = $1, updated_at = now() where sub = $2 returning sub, email, is_admin`
: `update public.${TABLE} set is_admin = $1, can_manage = $1, updated_at = now() where email = $2 returning sub, email, is_admin`;
const r = await pool.query(q, [isAdmin, sub ? sub : email]);
if (!r.rows?.length) return res.status(404).json({ ok: false, error: "not_found" });
res.json({ ok: true, user: r.rows[0] });
} catch {
res.status(500).json({ ok: false, error: "server_error" });
}
});
// Serve static site
app.use(express.static(__dirname, { extensions: ["html"] }));