dippoo commited on
Commit
4b21f3a
·
1 Parent(s): 5df7792

Add password protection

Browse files
Files changed (1) hide show
  1. src/content_engine/api/routes_ui.py +153 -4
src/content_engine/api/routes_ui.py CHANGED
@@ -1,23 +1,172 @@
1
- """Web UI route — serves the single-page dashboard."""
2
 
3
  from __future__ import annotations
4
 
 
 
 
5
  from pathlib import Path
6
 
7
- from fastapi import APIRouter
8
- from fastapi.responses import HTMLResponse, Response
9
 
10
  router = APIRouter(tags=["ui"])
11
 
12
  UI_HTML_PATH = Path(__file__).parent / "ui.html"
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  @router.get("/", response_class=HTMLResponse)
16
- async def dashboard():
17
  """Serve the main dashboard UI."""
 
 
 
18
  content = UI_HTML_PATH.read_text(encoding="utf-8")
19
  return Response(
20
  content=content,
21
  media_type="text/html",
22
  headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
23
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Web UI route — serves the single-page dashboard with password protection."""
2
 
3
  from __future__ import annotations
4
 
5
+ import hashlib
6
+ import os
7
+ import secrets
8
  from pathlib import Path
9
 
10
+ from fastapi import APIRouter, Request, Form, HTTPException
11
+ from fastapi.responses import HTMLResponse, Response, RedirectResponse
12
 
13
  router = APIRouter(tags=["ui"])
14
 
15
  UI_HTML_PATH = Path(__file__).parent / "ui.html"
16
 
17
+ # Simple session storage (in-memory, resets on restart)
18
+ _valid_sessions: set[str] = set()
19
+
20
+ # Get password from environment variable
21
+ APP_PASSWORD = os.environ.get("APP_PASSWORD", "")
22
+
23
+
24
+ def _check_session(request: Request) -> bool:
25
+ """Check if request has valid session."""
26
+ if not APP_PASSWORD:
27
+ return True # No password set, allow access
28
+ session_token = request.cookies.get("session")
29
+ return session_token in _valid_sessions
30
+
31
+
32
+ LOGIN_HTML = """
33
+ <!DOCTYPE html>
34
+ <html lang="en">
35
+ <head>
36
+ <meta charset="UTF-8">
37
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
38
+ <title>Login - Content Engine</title>
39
+ <style>
40
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
41
+ body {
42
+ font-family: 'Segoe UI', -apple-system, system-ui, sans-serif;
43
+ background: linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 100%);
44
+ color: #eee;
45
+ min-height: 100vh;
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: center;
49
+ }
50
+ .login-box {
51
+ background: #1a1a28;
52
+ border: 1px solid #2a2a3a;
53
+ border-radius: 16px;
54
+ padding: 40px;
55
+ width: 100%;
56
+ max-width: 400px;
57
+ box-shadow: 0 20px 60px rgba(0,0,0,0.5);
58
+ }
59
+ h1 {
60
+ font-size: 24px;
61
+ margin-bottom: 8px;
62
+ background: linear-gradient(135deg, #7c3aed, #ec4899);
63
+ -webkit-background-clip: text;
64
+ -webkit-text-fill-color: transparent;
65
+ }
66
+ .subtitle { color: #888; font-size: 14px; margin-bottom: 30px; }
67
+ label { display: block; font-size: 13px; color: #888; margin-bottom: 6px; }
68
+ input[type="password"] {
69
+ width: 100%;
70
+ padding: 12px 16px;
71
+ border-radius: 8px;
72
+ border: 1px solid #2a2a3a;
73
+ background: #0a0a0f;
74
+ color: #eee;
75
+ font-size: 16px;
76
+ margin-bottom: 20px;
77
+ }
78
+ input[type="password"]:focus { outline: none; border-color: #7c3aed; }
79
+ button {
80
+ width: 100%;
81
+ padding: 14px;
82
+ border-radius: 8px;
83
+ border: none;
84
+ background: linear-gradient(135deg, #7c3aed, #6d28d9);
85
+ color: white;
86
+ font-size: 16px;
87
+ font-weight: 600;
88
+ cursor: pointer;
89
+ transition: transform 0.1s, box-shadow 0.2s;
90
+ }
91
+ button:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(124, 58, 237, 0.4); }
92
+ .error { color: #ef4444; font-size: 13px; margin-bottom: 16px; }
93
+ </style>
94
+ </head>
95
+ <body>
96
+ <div class="login-box">
97
+ <h1>Content Engine</h1>
98
+ <p class="subtitle">Enter password to access</p>
99
+ {{ERROR}}
100
+ <form method="POST" action="/login">
101
+ <label>Password</label>
102
+ <input type="password" name="password" placeholder="Enter password" autofocus required>
103
+ <button type="submit">Login</button>
104
+ </form>
105
+ </div>
106
+ </body>
107
+ </html>
108
+ """
109
+
110
 
111
  @router.get("/", response_class=HTMLResponse)
112
+ async def dashboard(request: Request):
113
  """Serve the main dashboard UI."""
114
+ if not _check_session(request):
115
+ return RedirectResponse(url="/login", status_code=302)
116
+
117
  content = UI_HTML_PATH.read_text(encoding="utf-8")
118
  return Response(
119
  content=content,
120
  media_type="text/html",
121
  headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
122
  )
123
+
124
+
125
+ @router.get("/login", response_class=HTMLResponse)
126
+ async def login_page(request: Request, error: str = ""):
127
+ """Show login page."""
128
+ if not APP_PASSWORD:
129
+ return RedirectResponse(url="/", status_code=302)
130
+
131
+ if _check_session(request):
132
+ return RedirectResponse(url="/", status_code=302)
133
+
134
+ error_html = f'<p class="error">{error}</p>' if error else ""
135
+ html = LOGIN_HTML.replace("{{ERROR}}", error_html)
136
+ return Response(content=html, media_type="text/html")
137
+
138
+
139
+ @router.post("/login")
140
+ async def login_submit(password: str = Form(...)):
141
+ """Handle login form submission."""
142
+ if not APP_PASSWORD:
143
+ return RedirectResponse(url="/", status_code=302)
144
+
145
+ if password == APP_PASSWORD:
146
+ # Create session token
147
+ session_token = secrets.token_hex(32)
148
+ _valid_sessions.add(session_token)
149
+
150
+ response = RedirectResponse(url="/", status_code=302)
151
+ response.set_cookie(
152
+ key="session",
153
+ value=session_token,
154
+ httponly=True,
155
+ max_age=86400 * 7, # 7 days
156
+ samesite="lax",
157
+ )
158
+ return response
159
+ else:
160
+ return RedirectResponse(url="/login?error=Invalid+password", status_code=302)
161
+
162
+
163
+ @router.get("/logout")
164
+ async def logout(request: Request):
165
+ """Log out and clear session."""
166
+ session_token = request.cookies.get("session")
167
+ if session_token in _valid_sessions:
168
+ _valid_sessions.discard(session_token)
169
+
170
+ response = RedirectResponse(url="/login", status_code=302)
171
+ response.delete_cookie("session")
172
+ return response