Initial commit after re-install
This commit is contained in:
20
.cursor/rules/auto-commit-push.mdc
Normal file
20
.cursor/rules/auto-commit-push.mdc
Normal 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
24
.env.example
Normal 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
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Python
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
21
PROMPT.txt
Normal file
21
PROMPT.txt
Normal 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
242
README.md
Normal 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
1
ads.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
google.com, pub-5000757765244758, DIRECT, f08c47fec0942fa0
|
||||||
41
db/schema.sql
Normal file
41
db/schema.sql
Normal 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
BIN
favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 233 KiB |
591
flask_app.py
Normal file
591
flask_app.py
Normal 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
1108
index.html
Normal file
File diff suppressed because it is too large
Load Diff
133
links.json
Normal file
133
links.json
Normal 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
1007
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal 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
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Flask
|
||||||
|
python-dotenv
|
||||||
|
psycopg2-binary
|
||||||
|
PyJWT[crypto]
|
||||||
|
requests
|
||||||
|
flask-cors
|
||||||
|
|
||||||
292
server.js
Normal file
292
server.js
Normal 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
846
styles.css
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user