feat: add common utilities and feedback handling scripts
- Introduced common.js for shared functions including API calls, error reporting, and UI helpers. - Implemented feedback.js for the feedback reporting page, including form handling and displaying feedback status. - Added layout.js for managing galaxy and section layouts in the visualization. - Created login.js for user authentication with session management. - Developed search.js for term searching based on user input and synonyms. - Implemented signup.js for user registration with validation and immediate login.
This commit is contained in:
+178
@@ -0,0 +1,178 @@
|
||||
"""인증.
|
||||
|
||||
- 교수자(owner): admins 테이블의 아이디·비밀번호 계정. 용어·관계·문제 수정, 회원·반 관리, 설정.
|
||||
처음 실행할 때 아이디 ATLAS_OWNER_USERNAME(기본 professor), 비밀번호 ATLAS_OWNER_PASSWORD 또는
|
||||
data/.owner_password 로 계정을 만든다. 이후 비밀번호는 DB에 해시로만 저장된다.
|
||||
- 회원(member): 학생 계정. 교수자가 승인하면 사례·문제를 볼 수 있다. 학생·방문자는 오류 신고만 할 수 있다.
|
||||
모두 서명된 쿠키 세션을 쓴다.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .db import DB_PATH
|
||||
|
||||
DATA = DB_PATH.parent
|
||||
COOKIE = "atlas_admin"
|
||||
MEMBER_COOKIE = "atlas_member"
|
||||
TTL = 60 * 60 * 24 * 14
|
||||
|
||||
|
||||
def _read_or_create(path, factory):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.exists():
|
||||
return path.read_text().strip()
|
||||
v = factory()
|
||||
path.write_text(v)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return v
|
||||
|
||||
|
||||
def secret() -> bytes:
|
||||
return _read_or_create(DATA / ".secret", lambda: secrets.token_hex(32)).encode()
|
||||
|
||||
|
||||
def owner_password() -> str:
|
||||
env = os.environ.get("ATLAS_OWNER_PASSWORD")
|
||||
if env:
|
||||
return env
|
||||
return _read_or_create(DATA / ".owner_password", lambda: secrets.token_urlsafe(12))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 서명 토큰
|
||||
|
||||
def sign(payload: str) -> str:
|
||||
raw = payload.encode()
|
||||
sig = hmac.new(secret(), raw, hashlib.sha256).hexdigest()
|
||||
return base64.urlsafe_b64encode(raw).decode() + "." + sig
|
||||
|
||||
|
||||
def unsign(token: str | None) -> str | None:
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
b64, sig = token.rsplit(".", 1)
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(b64.encode())
|
||||
except Exception:
|
||||
return None
|
||||
if not hmac.compare_digest(hmac.new(secret(), raw, hashlib.sha256).hexdigest(), sig):
|
||||
return None
|
||||
return raw.decode()
|
||||
|
||||
|
||||
def make_token(name: str, role: str = "owner") -> str:
|
||||
return sign(f"{name}|{role}|{int(time.time()) + TTL}")
|
||||
|
||||
|
||||
def read_session(token: str | None) -> tuple[str, str] | None:
|
||||
"""(이름, 역할) 또는 None. 예전 형식(이름|만료)은 검수자로 본다."""
|
||||
p = unsign(token)
|
||||
if not p:
|
||||
return None
|
||||
parts = p.rsplit("|", 2)
|
||||
if len(parts) == 3:
|
||||
name, role, exp = parts
|
||||
else:
|
||||
name, exp = p.rsplit("|", 1)
|
||||
role = "reviewer"
|
||||
if not exp.isdigit() or int(exp) < time.time():
|
||||
return None
|
||||
return name, role
|
||||
|
||||
|
||||
def read_token(token: str | None) -> str | None:
|
||||
s = read_session(token)
|
||||
return s[0] if s else None
|
||||
|
||||
|
||||
def ensure_owner_account(con) -> dict | None:
|
||||
"""교수자 계정이 없으면 만든다. 새로 만들었으면 {username, password} 를 돌려준다."""
|
||||
if con.execute("SELECT 1 FROM admins").fetchone():
|
||||
return None
|
||||
username = os.environ.get("ATLAS_OWNER_USERNAME", "professor")
|
||||
pw = owner_password()
|
||||
with con:
|
||||
con.execute("INSERT INTO admins(username,password_hash,name,created_at) VALUES(?,?,?,?)",
|
||||
(username, hash_password(pw), "교수자", time.strftime("%Y-%m-%dT%H:%M:%S")))
|
||||
# 평문 비밀번호 파일은 계정을 만든 뒤 지운다 (이후엔 DB의 해시만 사용)
|
||||
f = DATA / ".owner_password"
|
||||
if not os.environ.get("ATLAS_OWNER_PASSWORD") and f.exists():
|
||||
f.unlink()
|
||||
return {"username": username, "password": pw}
|
||||
|
||||
|
||||
def check_login(con, username: str, pw: str) -> dict | None:
|
||||
"""교수자 아이디·비밀번호가 맞으면 계정 행"""
|
||||
r = con.execute("SELECT * FROM admins WHERE username=?", (username.strip(),)).fetchone()
|
||||
if r and verify_password(pw, r["password_hash"]):
|
||||
return dict(r)
|
||||
return None
|
||||
|
||||
|
||||
def current_admin(request: Request) -> str | None:
|
||||
"""교수자로 로그인했으면 이름 (예전 검수자 세션은 인정하지 않음)."""
|
||||
s = read_session(request.cookies.get(COOKIE))
|
||||
return s[0] if s and s[1] == "owner" else None
|
||||
|
||||
|
||||
def current_role(request: Request) -> str | None:
|
||||
s = read_session(request.cookies.get(COOKIE))
|
||||
return s[1] if s else None
|
||||
|
||||
|
||||
def require_admin(request: Request) -> str:
|
||||
name = current_admin(request)
|
||||
if not name:
|
||||
raise HTTPException(401, "교수자 로그인이 필요합니다.")
|
||||
return name
|
||||
|
||||
|
||||
def require_owner(request: Request) -> str:
|
||||
s = read_session(request.cookies.get(COOKIE))
|
||||
if not s:
|
||||
raise HTTPException(401, "관리자 로그인이 필요합니다.")
|
||||
if s[1] != "owner":
|
||||
raise HTTPException(401, "교수자 로그인이 필요합니다.")
|
||||
return s[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 회원 비밀번호·세션
|
||||
|
||||
def hash_password(pw: str) -> str:
|
||||
salt = os.urandom(16)
|
||||
h = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, 200_000)
|
||||
return f"pbkdf2${salt.hex()}${h.hex()}"
|
||||
|
||||
|
||||
def verify_password(pw: str, stored: str | None) -> bool:
|
||||
try:
|
||||
_, salt, h = (stored or "").split("$")
|
||||
except ValueError:
|
||||
return False
|
||||
calc = hashlib.pbkdf2_hmac("sha256", pw.encode(), bytes.fromhex(salt), 200_000).hex()
|
||||
return hmac.compare_digest(calc, h)
|
||||
|
||||
|
||||
def make_member_token(user_id: int) -> str:
|
||||
return sign(f"m|{user_id}|{int(time.time()) + TTL}")
|
||||
|
||||
|
||||
def member_id(request: Request) -> int | None:
|
||||
p = unsign(request.cookies.get(MEMBER_COOKIE))
|
||||
if not p:
|
||||
return None
|
||||
try:
|
||||
tag, uid, exp = p.split("|")
|
||||
except ValueError:
|
||||
return None
|
||||
if tag != "m" or int(exp) < time.time():
|
||||
return None
|
||||
return int(uid)
|
||||
Reference in New Issue
Block a user