Compare commits
10 Commits
19c6814d2f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c2d7dffc66 | |||
| 8140b1f6cd | |||
| 94453ccdae | |||
|
|
899cdf14d0 | ||
|
|
ea104aef6e | ||
|
|
aff4b7d961 | ||
|
|
1a41887e62 | ||
|
|
5ea6d6e5b0 | ||
|
|
86d9cdc894 | ||
|
|
c21a7b3739 |
31
.env
Normal file
31
.env
Normal 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
|
||||
24
.env.example
24
.env.example
@@ -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
3
.gitignore
vendored
@@ -1,8 +1,5 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Python
|
||||
.venv/
|
||||
|
||||
31
README.md
31
README.md
@@ -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`
|
||||
|
||||
@@ -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 (
|
||||
|
||||
207
flask_app.py
207
flask_app.py
@@ -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)
|
||||
|
||||
106
index.html
106
index.html
@@ -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();
|
||||
});
|
||||
|
||||
93
script.js
93
script.js
@@ -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
128
server.js
@@ -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"] }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user