small-software 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- small_cli/__init__.py +1 -0
- small_cli/__main__.py +3 -0
- small_cli/access.py +114 -0
- small_cli/archive.py +127 -0
- small_cli/cli.py +76 -0
- small_cli/commands/__init__.py +8 -0
- small_cli/commands/access.py +167 -0
- small_cli/commands/dashboard.py +49 -0
- small_cli/commands/db.py +154 -0
- small_cli/commands/deploy.py +248 -0
- small_cli/commands/destroy.py +63 -0
- small_cli/commands/domain.py +123 -0
- small_cli/commands/env.py +151 -0
- small_cli/commands/init.py +86 -0
- small_cli/commands/link.py +53 -0
- small_cli/commands/logs.py +59 -0
- small_cli/commands/mail.py +203 -0
- small_cli/commands/notify.py +147 -0
- small_cli/commands/sleep.py +61 -0
- small_cli/common.py +92 -0
- small_cli/config.py +82 -0
- small_cli/dashboard.py +648 -0
- small_cli/i18n.py +55 -0
- small_cli/railway.py +369 -0
- small_cli/scaffold.py +81 -0
- small_cli/templates/fastapi/magic_link.py +675 -0
- small_cli/templates/fastapi/main.py +67 -0
- small_cli/templates/fastapi/requirements.txt +2 -0
- small_cli/templates/node-http/magic-link.js +509 -0
- small_cli/templates/node-http/package.json +11 -0
- small_cli/templates/node-http/server.js +33 -0
- small_cli/templates/python-http/app.py +47 -0
- small_cli/templates/python-http/magic_link.py +675 -0
- small_cli/templates/streamlit/app.py +181 -0
- small_cli/templates/streamlit/requirements.txt +1 -0
- small_software-0.6.0.dist-info/METADATA +559 -0
- small_software-0.6.0.dist-info/RECORD +41 -0
- small_software-0.6.0.dist-info/WHEEL +5 -0
- small_software-0.6.0.dist-info/entry_points.txt +2 -0
- small_software-0.6.0.dist-info/licenses/LICENSE +21 -0
- small_software-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
"""Защита веб-приложения: вход по рабочей почте или по ссылке-приглашению. Один файл, без зависимостей, Python 3.8+.
|
|
2
|
+
|
|
3
|
+
from magic_link import MagicLink, MagicLinkASGI
|
|
4
|
+
application = MagicLink(application) # WSGI: Django; Flask — app.wsgi_app = MagicLink(app.wsgi_app)
|
|
5
|
+
app.add_middleware(MagicLinkASGI) # ASGI: FastAPI, Starlette; или app = MagicLinkASGI(app)
|
|
6
|
+
|
|
7
|
+
Два способа пустить человека:
|
|
8
|
+
* Вход по почте — как доступ к Google Doc. Владелец перечисляет адреса в ACCESS_EMAILS. Человек открывает
|
|
9
|
+
приложение, вводит свою почту и получает письмо со ссылкой для входа (15 минут, один раз). Войти может
|
|
10
|
+
только тот, кто читает эту почту; браузер помнит вход 30 дней. Незнакомый адрес — это запрос доступа.
|
|
11
|
+
* Ссылка-приглашение https://<app>/?token=<токен> (ACCESS_TOKEN): middleware ставит куку и перенаправляет
|
|
12
|
+
на тот же адрес без токена. Ссылка работает как пароль: войдёт любой, кому её переслали.
|
|
13
|
+
Без доступа — страница 403: форма входа по почте (если задан ACCESS_EMAILS) или кнопка «Запросить доступ».
|
|
14
|
+
Страницы и письма — на русском или английском, по языку браузера.
|
|
15
|
+
|
|
16
|
+
Переменные окружения:
|
|
17
|
+
ACCESS_EMAILS адреса через запятую; "@company.com" — весь домен. Убрали адрес — вход перестаёт
|
|
18
|
+
работать сразу, в том числе по уже выданной куке.
|
|
19
|
+
SESSION_SECRET ключ подписи ссылок для входа и сессий (small создаёт его сам). Не задан — случайный
|
|
20
|
+
на каждый запуск: после перезапуска всем придётся войти заново.
|
|
21
|
+
RESEND_API_KEY отправлять письма через Resend (HTTPS — работает и на Railway Hobby);
|
|
22
|
+
MAIL_FROM отправитель, например "Reports <noreply@company.com>" (домен подтверждён в Resend)
|
|
23
|
+
SMTP_URL smtp://логин:пароль@сервер:587 или smtps://...:465 (на Railway SMTP — только с тарифа Pro)
|
|
24
|
+
LOGIN_LINKS_TO_LOG 1 — не слать письма, а писать ссылку для входа в лог. Только для проверки.
|
|
25
|
+
PUBLIC_URL адрес приложения для ссылок в письмах (например, свой домен). Не задан — берётся
|
|
26
|
+
RAILWAY_PUBLIC_DOMAIN; локально — localhost.
|
|
27
|
+
ACCESS_LANG язык страниц и писем: ru, en или auto (по умолчанию — по заголовку Accept-Language)
|
|
28
|
+
OWNER_LANG язык сообщений для владельца — строк лога и уведомлений в Telegram: en (по умолчанию) или ru.
|
|
29
|
+
small deploy задаёт его по языку small.
|
|
30
|
+
ACCESS_TOKEN токены ссылок-приглашений через запятую, можно с именами: "alice:токен1,bob:токен2".
|
|
31
|
+
Убрали чей-то токен — его ссылка и выданные по ней куки перестают работать.
|
|
32
|
+
ACCESS_REQUEST_URL куда ведёт кнопка «Запросить доступ» (mailto:..., https://t.me/...), если нет входа по
|
|
33
|
+
почте. Не задан — запрос печатается в лог строкой [access-request]
|
|
34
|
+
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
|
|
35
|
+
запросы доступа приходят владельцу в Telegram (подключить: small notify telegram)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import base64
|
|
39
|
+
import hashlib
|
|
40
|
+
import hmac
|
|
41
|
+
import html
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import secrets
|
|
46
|
+
import smtplib
|
|
47
|
+
import threading
|
|
48
|
+
import time
|
|
49
|
+
import urllib.error
|
|
50
|
+
import urllib.request
|
|
51
|
+
from email.message import EmailMessage
|
|
52
|
+
from http.cookies import CookieError, SimpleCookie
|
|
53
|
+
from string import Template
|
|
54
|
+
from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit
|
|
55
|
+
|
|
56
|
+
COOKIE_NAME = "small_access"
|
|
57
|
+
COOKIE_MAX_AGE = 30 * 24 * 3600
|
|
58
|
+
LINK_TTL = 15 * 60
|
|
59
|
+
REQUEST_PATH = "/_access/request"
|
|
60
|
+
LOGIN_PATH = "/_access/login"
|
|
61
|
+
VERIFY_PATH = "/_access/verify"
|
|
62
|
+
LOGOUT_PATH = "/_access/logout"
|
|
63
|
+
# Сколько писем для входа можно запросить за 15 минут: на один адрес и с одного IP.
|
|
64
|
+
LIMIT_PER_EMAIL, LIMIT_PER_IP, LIMIT_WINDOW = 3, 10, 15 * 60
|
|
65
|
+
|
|
66
|
+
EMAIL_RE = re.compile(r"^[^@\s,;<>\"']+@[^@\s,;<>\"']+\.[^@\s,;<>\"']+$")
|
|
67
|
+
DOMAIN_RE = re.compile(r"^@[^@\s,;<>\"']+\.[^@\s,;<>\"']+$")
|
|
68
|
+
STATUS = {200: "200 OK", 302: "302 Found", 400: "400 Bad Request", 403: "403 Forbidden", 429: "429 Too Many Requests"}
|
|
69
|
+
LANGS = ("ru", "en")
|
|
70
|
+
DEFAULT_LANG = "ru" # если браузер не сказал, какой язык предпочитает
|
|
71
|
+
|
|
72
|
+
MESSAGES = {
|
|
73
|
+
"ru": {
|
|
74
|
+
"forbidden": ("🔒", "Доступ по приглашению",
|
|
75
|
+
"Эта страница открывается только по ссылке-приглашению от владельца."),
|
|
76
|
+
"invalid": ("🔒", "Ссылка недействительна",
|
|
77
|
+
"Ссылка устарела или скопирована не полностью. Запросите новую."),
|
|
78
|
+
"sent": ("✅", "Запрос отправлен", "Владелец увидит запрос и пришлёт вам ссылку-приглашение."),
|
|
79
|
+
"login": ("🔒", "Вход по рабочей почте",
|
|
80
|
+
"Приложение открывается только для приглашённых. Введите рабочую почту — пришлём ссылку для входа."),
|
|
81
|
+
"check": ("✉️", "Проверьте почту",
|
|
82
|
+
"Если у адреса есть доступ, ссылка для входа уже в почте: она действует 15 минут. "
|
|
83
|
+
"Если доступа нет, владелец получит ваш запрос."),
|
|
84
|
+
"bad_email": ("⚠️", "Проверьте адрес", "Похоже, в адресе почты ошибка. Введите его ещё раз."),
|
|
85
|
+
"slow": ("⌛", "Слишком много попыток", "Подождите 15 минут и попробуйте снова."),
|
|
86
|
+
"expired": ("⌛", "Ссылка для входа устарела",
|
|
87
|
+
"Ссылка действует 15 минут и только один раз. Введите почту — пришлём новую."),
|
|
88
|
+
"confirm": ("🔒", "Вход в приложение", "Нажмите кнопку, чтобы войти как %s."),
|
|
89
|
+
"logout": ("👋", "Вы вышли", "Чтобы войти снова, введите рабочую почту."),
|
|
90
|
+
},
|
|
91
|
+
"en": {
|
|
92
|
+
"forbidden": ("🔒", "Invitation only", "This page opens only with an invitation link from its owner."),
|
|
93
|
+
"invalid": ("🔒", "Invalid link", "The link has expired or was copied incompletely. Ask for a new one."),
|
|
94
|
+
"sent": ("✅", "Request sent", "The owner will see your request and send you an invitation link."),
|
|
95
|
+
"login": ("🔒", "Sign in with your work email",
|
|
96
|
+
"This app is for invited people only. Enter your work email and we'll send you a sign-in link."),
|
|
97
|
+
"check": ("✉️", "Check your email",
|
|
98
|
+
"If this address has access, a sign-in link is already in your inbox. It works for 15 minutes. "
|
|
99
|
+
"If it doesn't, the owner will get your request."),
|
|
100
|
+
"bad_email": ("⚠️", "Check the address", "This email address looks wrong. Please enter it again."),
|
|
101
|
+
"slow": ("⌛", "Too many attempts", "Please wait 15 minutes and try again."),
|
|
102
|
+
"expired": ("⌛", "Sign-in link expired",
|
|
103
|
+
"The link works for 15 minutes and only once. Enter your email to get a new one."),
|
|
104
|
+
"confirm": ("🔒", "Sign in", "Click the button to sign in as %s."),
|
|
105
|
+
"logout": ("👋", "Signed out", "To sign in again, enter your work email."),
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
UI = {
|
|
110
|
+
"ru": {"request": "Запросить доступ", "contact": "Email или Telegram для ответа",
|
|
111
|
+
"get_link": "Получить ссылку для входа", "sign_in": "Войти", "other": "Ввести другой адрес",
|
|
112
|
+
"subject": "Вход в %s", "mail_link": "Войти",
|
|
113
|
+
"mail_text": "Ссылка для входа в %s:\n%s\n\nОна действует 15 минут и только один раз. "
|
|
114
|
+
"Если вы не запрашивали вход, просто удалите это письмо.\n",
|
|
115
|
+
"mail_intro": "Ссылка для входа в <b>%s</b>:",
|
|
116
|
+
"mail_note": "Она действует 15 минут и только один раз. Если вы не запрашивали вход, просто удалите это письмо."},
|
|
117
|
+
"en": {"request": "Request access", "contact": "Email or Telegram to reach you",
|
|
118
|
+
"get_link": "Email me a sign-in link", "sign_in": "Sign in", "other": "Use a different address",
|
|
119
|
+
"subject": "Sign in to %s", "mail_link": "Sign in",
|
|
120
|
+
"mail_text": "Sign-in link for %s:\n%s\n\nIt works for 15 minutes and only once. "
|
|
121
|
+
"If you didn't ask to sign in, just delete this email.\n",
|
|
122
|
+
"mail_intro": "Sign-in link for <b>%s</b>:",
|
|
123
|
+
"mail_note": "It works for 15 minutes and only once. If you didn't ask to sign in, just delete this email."},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_tokens(value):
|
|
128
|
+
""""alice:tok1, tok2" -> [("alice", "tok1"), ("", "tok2")]. В самом токене двоеточия быть не должно."""
|
|
129
|
+
items = value.split(",") if isinstance(value, str) else value
|
|
130
|
+
entries = []
|
|
131
|
+
for item in items:
|
|
132
|
+
name, _, token = item.strip().rpartition(":")
|
|
133
|
+
if token.strip():
|
|
134
|
+
entries.append((name.strip(), token.strip()))
|
|
135
|
+
return entries
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_emails(value):
|
|
139
|
+
""""Ivan@Company.com, @team.com, мусор" -> {"ivan@company.com", "@team.com"}: адреса и домены, без регистра."""
|
|
140
|
+
items = value.split(",") if isinstance(value, str) else value
|
|
141
|
+
found = set()
|
|
142
|
+
for item in items:
|
|
143
|
+
item = item.strip().lower()
|
|
144
|
+
if EMAIL_RE.match(item) or DOMAIN_RE.match(item):
|
|
145
|
+
found.add(item)
|
|
146
|
+
return found
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def pick_lang(accept_language, setting="auto"):
|
|
150
|
+
"""Язык страницы: ACCESS_LANG (ru/en), иначе самый предпочтительный из ru/en в Accept-Language."""
|
|
151
|
+
if setting in LANGS:
|
|
152
|
+
return setting
|
|
153
|
+
if not (accept_language or "").strip():
|
|
154
|
+
return DEFAULT_LANG
|
|
155
|
+
best, best_q = "en", -1.0 # браузер просит другой язык (de, fr…) — английский понятнее, чем русский
|
|
156
|
+
for part in (accept_language or "").split(","):
|
|
157
|
+
tag, _, params = part.strip().partition(";")
|
|
158
|
+
base = tag.strip().lower().split("-")[0]
|
|
159
|
+
if base == "*": # «любой язык» (так, например, пишет fetch в Node)
|
|
160
|
+
base = DEFAULT_LANG
|
|
161
|
+
try:
|
|
162
|
+
q = float(params.strip()[2:]) if params.strip().startswith("q=") else 1.0
|
|
163
|
+
except ValueError:
|
|
164
|
+
q = 0.0
|
|
165
|
+
if base in LANGS and q > best_q:
|
|
166
|
+
best, best_q = base, q
|
|
167
|
+
return best
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class _Gate:
|
|
171
|
+
"""Общая логика WSGI- и ASGI-версий: кто может войти, куки, письма, страницы, логи."""
|
|
172
|
+
|
|
173
|
+
def __init__(self, tokens, request_url, cookie_name, max_age, emails=None, secret=None):
|
|
174
|
+
self.entries = parse_tokens(os.environ.get("ACCESS_TOKEN", "") if tokens is None else tokens)
|
|
175
|
+
self.cookies = [(name, _cookie_value(token)) for name, token in self.entries]
|
|
176
|
+
self.emails = parse_emails(os.environ.get("ACCESS_EMAILS", "") if emails is None else emails)
|
|
177
|
+
secret = os.environ.get("SESSION_SECRET", "").strip() if secret is None else secret
|
|
178
|
+
if self.emails and not secret:
|
|
179
|
+
_log(_for_owner("[magic-link] SESSION_SECRET is not set: everyone will have to sign in again after a restart",
|
|
180
|
+
"[magic-link] SESSION_SECRET не задан: после перезапуска приложения всем придётся войти заново"))
|
|
181
|
+
self.secret = (secret or secrets.token_hex(32)).encode("utf-8")
|
|
182
|
+
self.request_url = os.environ.get("ACCESS_REQUEST_URL", "") if request_url is None else request_url
|
|
183
|
+
self.cookie_name = cookie_name
|
|
184
|
+
self.telegram = (os.environ.get("TELEGRAM_BOT_TOKEN", "").strip(), os.environ.get("TELEGRAM_CHAT_ID", "").strip())
|
|
185
|
+
self.lang = os.environ.get("ACCESS_LANG", "auto").strip().lower()
|
|
186
|
+
self.max_age = max_age
|
|
187
|
+
self.used_links = {} # одноразовые ссылки для входа: nonce -> срок
|
|
188
|
+
self.attempts = {} # ограничение частоты писем: ключ -> [время запросов]
|
|
189
|
+
self.lock = threading.Lock()
|
|
190
|
+
if not self.entries and not self.emails:
|
|
191
|
+
_log(_for_owner("[magic-link] neither ACCESS_TOKEN nor ACCESS_EMAILS is set: nobody can get in",
|
|
192
|
+
"[magic-link] ACCESS_TOKEN и ACCESS_EMAILS не заданы: доступ закрыт для всех"))
|
|
193
|
+
if self.emails and not _mail_mode():
|
|
194
|
+
_log(_for_owner("[magic-link] email is not set up (RESEND_API_KEY or SMTP_URL): sign-in emails will not be "
|
|
195
|
+
"sent. Set it up: small mail",
|
|
196
|
+
"[magic-link] почта не настроена (RESEND_API_KEY или SMTP_URL): письма для входа не уйдут. "
|
|
197
|
+
"Подключить: small mail"))
|
|
198
|
+
|
|
199
|
+
# --- общий обработчик: None — пропустить в приложение, иначе (код, заголовки, тело) ---
|
|
200
|
+
|
|
201
|
+
def wants_body(self, method, path):
|
|
202
|
+
return method == "POST" and ((path == REQUEST_PATH and not self.request_url)
|
|
203
|
+
or (bool(self.emails) and path in (LOGIN_PATH, VERIFY_PATH)))
|
|
204
|
+
|
|
205
|
+
def dispatch(self, method, base, path, path_bytes, query, cookie, ip, user_agent, host, https, body=b"",
|
|
206
|
+
accept_language=""):
|
|
207
|
+
lang = pick_lang(accept_language, self.lang)
|
|
208
|
+
if path == REQUEST_PATH and method == "POST" and not self.request_url:
|
|
209
|
+
self.log_request(body, ip, user_agent, host)
|
|
210
|
+
return self.respond(200, "sent", base, lang=lang)
|
|
211
|
+
if self.emails and path == LOGIN_PATH and method == "POST":
|
|
212
|
+
return self.login_request(body, ip, user_agent, host, https, base, lang)
|
|
213
|
+
if self.emails and path == VERIFY_PATH:
|
|
214
|
+
return self.verify(method, query, body, ip, https, base, lang)
|
|
215
|
+
if path == LOGOUT_PATH:
|
|
216
|
+
return self.respond(200, "logout" if self.emails else "forbidden", base,
|
|
217
|
+
[("Set-Cookie", "%s=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax" % self.cookie_name)],
|
|
218
|
+
lang=lang)
|
|
219
|
+
|
|
220
|
+
token = next((value for key, value in query if key == "token"), None)
|
|
221
|
+
if token is not None:
|
|
222
|
+
name = self.token_owner(token)
|
|
223
|
+
if name is None:
|
|
224
|
+
return self.respond(403, "invalid", base, lang=lang)
|
|
225
|
+
self.log_login(name, ip)
|
|
226
|
+
return 302, [("Location", self.location(path_bytes, query)), ("Set-Cookie", self.set_cookie(token, https)),
|
|
227
|
+
("Cache-Control", "no-store")], b""
|
|
228
|
+
|
|
229
|
+
if self.cookie_owner(cookie) is not None:
|
|
230
|
+
return None
|
|
231
|
+
return self.respond(403, "forbidden", base, lang=lang)
|
|
232
|
+
|
|
233
|
+
def respond(self, status, kind, base, headers=(), lang=DEFAULT_LANG, **page):
|
|
234
|
+
return status, [("Content-Type", "text/html; charset=utf-8"), ("Cache-Control", "no-store"),
|
|
235
|
+
("Referrer-Policy", "no-referrer"), ("Content-Language", lang)] + list(headers), \
|
|
236
|
+
self.page(kind, base, lang=lang, **page)
|
|
237
|
+
|
|
238
|
+
# --- ссылки-приглашения (ACCESS_TOKEN) ---
|
|
239
|
+
|
|
240
|
+
def token_owner(self, token):
|
|
241
|
+
"""Имя владельца токена ("" — без имени) или None, если токен неверный."""
|
|
242
|
+
return _owner(token, self.entries)
|
|
243
|
+
|
|
244
|
+
def cookie_owner(self, cookie_header):
|
|
245
|
+
"""Кто вошёл: имя из ACCESS_TOKEN ("" — без имени), адрес почты или None."""
|
|
246
|
+
try:
|
|
247
|
+
morsel = SimpleCookie(cookie_header or "").get(self.cookie_name)
|
|
248
|
+
except CookieError:
|
|
249
|
+
return None
|
|
250
|
+
if morsel is None:
|
|
251
|
+
return None
|
|
252
|
+
if morsel.value.startswith("e."):
|
|
253
|
+
data = self.unsign("session", morsel.value[2:])
|
|
254
|
+
email = data.get("e") if data else None
|
|
255
|
+
return email if email and self.email_allowed(email) else None
|
|
256
|
+
return _owner(morsel.value, self.cookies)
|
|
257
|
+
|
|
258
|
+
def set_cookie(self, token, https):
|
|
259
|
+
return self._cookie(_cookie_value(token), https)
|
|
260
|
+
|
|
261
|
+
def _cookie(self, value, https):
|
|
262
|
+
return "%s=%s; Path=/; Max-Age=%d; HttpOnly; SameSite=Lax%s" % (
|
|
263
|
+
self.cookie_name, value, self.max_age, "; Secure" if https else "")
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def location(path_bytes, query):
|
|
267
|
+
# Убираем токен из адреса, чтобы он не оставался в истории браузера и не утекал через Referer.
|
|
268
|
+
# Ведущие слэши схлопываем: Location "//host" был бы открытым редиректом.
|
|
269
|
+
location = "/" + quote(path_bytes, safe="/").lstrip("/")
|
|
270
|
+
rest = urlencode([(k, v) for k, v in query if k != "token"])
|
|
271
|
+
return location + ("?" + rest if rest else "")
|
|
272
|
+
|
|
273
|
+
# --- вход по почте (ACCESS_EMAILS) ---
|
|
274
|
+
|
|
275
|
+
def email_allowed(self, email):
|
|
276
|
+
email = email.lower()
|
|
277
|
+
return email in self.emails or "@" + email.rpartition("@")[2] in self.emails
|
|
278
|
+
|
|
279
|
+
def sign(self, purpose, data):
|
|
280
|
+
payload = _b64(json.dumps(data, separators=(",", ":")).encode("utf-8"))
|
|
281
|
+
return payload + "." + self._mac(purpose, payload)
|
|
282
|
+
|
|
283
|
+
def unsign(self, purpose, value):
|
|
284
|
+
"""Данные из подписанной строки, если подпись верна и срок не вышел; иначе None."""
|
|
285
|
+
payload, _, mac = (value or "").partition(".")
|
|
286
|
+
if not payload or not hmac.compare_digest(mac.encode("ascii", "replace"), self._mac(purpose, payload).encode()):
|
|
287
|
+
return None
|
|
288
|
+
try:
|
|
289
|
+
data = json.loads(_unb64(payload))
|
|
290
|
+
except ValueError:
|
|
291
|
+
return None
|
|
292
|
+
if not isinstance(data, dict) or not isinstance(data.get("x"), int) or data["x"] < time.time():
|
|
293
|
+
return None
|
|
294
|
+
return data
|
|
295
|
+
|
|
296
|
+
def _mac(self, purpose, payload):
|
|
297
|
+
# purpose разводит ссылку для входа и сессию: одну нельзя выдать за другую.
|
|
298
|
+
return _b64(hmac.new(self.secret, (purpose + "|" + payload).encode("utf-8"), hashlib.sha256).digest())
|
|
299
|
+
|
|
300
|
+
def login_request(self, body, ip, user_agent, host, https, base, lang=DEFAULT_LANG):
|
|
301
|
+
email = dict(parse_qsl(body.decode("utf-8", "replace"))).get("email", "").strip().lower()[:254]
|
|
302
|
+
if not EMAIL_RE.match(email):
|
|
303
|
+
return self.respond(400, "bad_email", base, lang=lang)
|
|
304
|
+
if not (self._allow("ip:" + ip, LIMIT_PER_IP) and self._allow("mail:" + email, LIMIT_PER_EMAIL)):
|
|
305
|
+
return self.respond(429, "slow", base, lang=lang)
|
|
306
|
+
if self.email_allowed(email):
|
|
307
|
+
app_url = self.public_url(host, https, base)
|
|
308
|
+
if app_url is None:
|
|
309
|
+
_log(_for_owner("[magic-link] sign-in email not sent: the app's address is unknown. Set PUBLIC_URL",
|
|
310
|
+
"[magic-link] письмо для входа не отправлено: неизвестен адрес приложения. Задайте PUBLIC_URL"))
|
|
311
|
+
else:
|
|
312
|
+
link = app_url + VERIFY_PATH + "?" + urlencode({"t": self.sign("login", {
|
|
313
|
+
"e": email, "x": int(time.time()) + LINK_TTL, "n": secrets.token_urlsafe(9)})})
|
|
314
|
+
# В фоне: ответ не ждёт почту и одинаков для известных и неизвестных адресов.
|
|
315
|
+
threading.Thread(target=_send_login_mail, args=(email, link, urlsplit(app_url).netloc, lang),
|
|
316
|
+
daemon=True).start()
|
|
317
|
+
else:
|
|
318
|
+
self.notify_request(email, ip, user_agent, host)
|
|
319
|
+
return self.respond(200, "check", base, lang=lang)
|
|
320
|
+
|
|
321
|
+
def verify(self, method, query, body, ip, https, base, lang=DEFAULT_LANG):
|
|
322
|
+
source = parse_qsl(body.decode("utf-8", "replace")) if method == "POST" else query
|
|
323
|
+
data = self.unsign("login", dict(source).get("t", ""))
|
|
324
|
+
email = data.get("e") if data else None
|
|
325
|
+
fresh = email and self.email_allowed(email) and data.get("n") not in self.used_links
|
|
326
|
+
if not fresh:
|
|
327
|
+
return self.respond(403, "expired", base, lang=lang)
|
|
328
|
+
if method != "POST":
|
|
329
|
+
# Почтовые сканеры сами открывают ссылки из писем. Вход — только по кнопке, иначе сканер «потратит» ссылку.
|
|
330
|
+
return self.respond(200, "confirm", base, lang=lang, email=email, token=dict(query)["t"])
|
|
331
|
+
with self.lock:
|
|
332
|
+
now = time.time()
|
|
333
|
+
self.used_links = {n: x for n, x in self.used_links.items() if x > now}
|
|
334
|
+
if data["n"] in self.used_links:
|
|
335
|
+
return self.respond(403, "expired", base, lang=lang)
|
|
336
|
+
self.used_links[data["n"]] = data["x"]
|
|
337
|
+
_log(_for_owner("[magic-link] signed in by email (%s), ip=%s", "[magic-link] вход по почте (%s), ip=%s") % (email, ip))
|
|
338
|
+
session = "e." + self.sign("session", {"e": email, "x": int(time.time()) + self.max_age})
|
|
339
|
+
return 302, [("Location", (base or "") + "/"), ("Set-Cookie", self._cookie(session, https)),
|
|
340
|
+
("Cache-Control", "no-store")], b""
|
|
341
|
+
|
|
342
|
+
def public_url(self, host, https, base):
|
|
343
|
+
"""Адрес приложения для ссылки в письме. Заголовку Host верим только локально: иначе злоумышленник
|
|
344
|
+
подставил бы свой домен, и ссылка для входа из настоящего письма ушла бы к нему."""
|
|
345
|
+
explicit = os.environ.get("PUBLIC_URL", "").strip().rstrip("/")
|
|
346
|
+
if explicit:
|
|
347
|
+
return explicit
|
|
348
|
+
domain = os.environ.get("RAILWAY_PUBLIC_DOMAIN", "").strip()
|
|
349
|
+
if domain:
|
|
350
|
+
return "https://" + domain + base
|
|
351
|
+
hostname = (host or "").rsplit(":", 1)[0].strip("[]").lower()
|
|
352
|
+
if hostname in ("localhost", "127.0.0.1", "::1") or hostname.endswith(".localhost"):
|
|
353
|
+
return ("https://" if https else "http://") + host + base
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
def _allow(self, key, limit):
|
|
357
|
+
now = time.time()
|
|
358
|
+
with self.lock:
|
|
359
|
+
if len(self.attempts) > 10000:
|
|
360
|
+
self.attempts = {k: v for k, v in self.attempts.items() if v[-1] > now - LIMIT_WINDOW}
|
|
361
|
+
recent = [t for t in self.attempts.get(key, []) if t > now - LIMIT_WINDOW]
|
|
362
|
+
if len(recent) >= limit:
|
|
363
|
+
self.attempts[key] = recent
|
|
364
|
+
return False
|
|
365
|
+
self.attempts[key] = recent + [now]
|
|
366
|
+
return True
|
|
367
|
+
|
|
368
|
+
# --- страницы и журнал ---
|
|
369
|
+
|
|
370
|
+
def page(self, kind, base, email="", token="", lang=DEFAULT_LANG):
|
|
371
|
+
if kind == "forbidden" and self.emails:
|
|
372
|
+
kind = "login"
|
|
373
|
+
icon, title, text = MESSAGES[lang][kind]
|
|
374
|
+
ui = UI[lang]
|
|
375
|
+
if kind == "confirm":
|
|
376
|
+
text = text % email
|
|
377
|
+
if kind == "sent":
|
|
378
|
+
action = ""
|
|
379
|
+
elif kind == "check":
|
|
380
|
+
action = '<a class="link" href="%s/">%s</a>' % (html.escape(base), ui["other"])
|
|
381
|
+
elif kind == "confirm":
|
|
382
|
+
action = CONFIRM_FORM.substitute(action=html.escape(base + VERIFY_PATH), token=html.escape(token),
|
|
383
|
+
button=ui["sign_in"])
|
|
384
|
+
elif self.emails:
|
|
385
|
+
action = LOGIN_FORM.substitute(action=html.escape(base + LOGIN_PATH), button=ui["get_link"])
|
|
386
|
+
elif self.request_url:
|
|
387
|
+
action = '<a class="btn" href="%s">%s</a>' % (html.escape(self.request_url), ui["request"])
|
|
388
|
+
else:
|
|
389
|
+
action = REQUEST_FORM.substitute(action=html.escape(base + REQUEST_PATH), placeholder=ui["contact"],
|
|
390
|
+
button=ui["request"])
|
|
391
|
+
return PAGE.substitute(lang=lang, icon=icon, title=html.escape(title), text=html.escape(text),
|
|
392
|
+
action=action).encode("utf-8")
|
|
393
|
+
|
|
394
|
+
def log_request(self, body, ip, user_agent, host=""):
|
|
395
|
+
contact = dict(parse_qsl(body.decode("utf-8", "replace"))).get("contact", "")[:200]
|
|
396
|
+
self.notify_request(contact, ip, user_agent, host)
|
|
397
|
+
|
|
398
|
+
def notify_request(self, contact, ip, user_agent, host=""):
|
|
399
|
+
# repr() экранирует переводы строк: пользователь не сможет подделать строки лога.
|
|
400
|
+
_log("[access-request] contact=%r ip=%s ua=%r" % (contact, ip, user_agent[:200]))
|
|
401
|
+
token, chat = self.telegram
|
|
402
|
+
if token and chat:
|
|
403
|
+
hint = "small access add " + (contact if EMAIL_RE.match(contact) else _for_owner("NAME", "ИМЯ"))
|
|
404
|
+
text = _for_owner("🔑 Access request to %s\nContact: %s\nIP: %s\n\nGrant access: %s",
|
|
405
|
+
"🔑 Запрос доступа к %s\nКонтакт: %s\nIP: %s\n\nВыдать доступ: %s") % (
|
|
406
|
+
host or _for_owner("the app", "приложению"), contact or "—", ip or "—", hint)
|
|
407
|
+
# В фоне: пользователь не ждёт Telegram. Без parse_mode — текст из формы ничего не разметит.
|
|
408
|
+
threading.Thread(target=_send_telegram, args=(token, chat, text), daemon=True).start()
|
|
409
|
+
|
|
410
|
+
@staticmethod
|
|
411
|
+
def log_login(name, ip):
|
|
412
|
+
_log(_for_owner("[magic-link] signed in by link%s, ip=%s", "[magic-link] вход по ссылке%s, ip=%s") % (
|
|
413
|
+
" (%s)" % name if name else "", ip))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class MagicLink:
|
|
417
|
+
"""WSGI-middleware."""
|
|
418
|
+
|
|
419
|
+
def __init__(self, app, tokens=None, request_url=None, cookie_name=COOKIE_NAME, max_age=COOKIE_MAX_AGE,
|
|
420
|
+
emails=None, secret=None):
|
|
421
|
+
self.app = app
|
|
422
|
+
self.gate = _Gate(tokens, request_url, cookie_name, max_age, emails, secret)
|
|
423
|
+
|
|
424
|
+
def __call__(self, environ, start_response):
|
|
425
|
+
gate = self.gate
|
|
426
|
+
method = environ.get("REQUEST_METHOD", "GET")
|
|
427
|
+
base = environ.get("SCRIPT_NAME", "")
|
|
428
|
+
path = environ.get("PATH_INFO", "") or "/"
|
|
429
|
+
body = b""
|
|
430
|
+
if gate.wants_body(method, path):
|
|
431
|
+
try:
|
|
432
|
+
size = min(int(environ.get("CONTENT_LENGTH") or 0), 4096)
|
|
433
|
+
except ValueError:
|
|
434
|
+
size = 0
|
|
435
|
+
body = environ["wsgi.input"].read(size)
|
|
436
|
+
forwarded = environ.get("HTTP_X_FORWARDED_FOR")
|
|
437
|
+
result = gate.dispatch(
|
|
438
|
+
method, base, path, (base + path).encode("latin-1", "replace"),
|
|
439
|
+
parse_qsl(environ.get("QUERY_STRING", ""), keep_blank_values=True), environ.get("HTTP_COOKIE", ""),
|
|
440
|
+
_client_ip(forwarded, environ.get("REMOTE_ADDR")), environ.get("HTTP_USER_AGENT", ""),
|
|
441
|
+
environ.get("HTTP_HOST", ""), _is_https(environ.get("HTTP_X_FORWARDED_PROTO"), environ.get("wsgi.url_scheme")),
|
|
442
|
+
body, environ.get("HTTP_ACCEPT_LANGUAGE", ""))
|
|
443
|
+
if result is None:
|
|
444
|
+
return self.app(environ, start_response)
|
|
445
|
+
status, headers, payload = result
|
|
446
|
+
start_response(STATUS[status], headers + [("Content-Length", str(len(payload)))])
|
|
447
|
+
return [payload]
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class MagicLinkASGI:
|
|
451
|
+
"""ASGI-middleware (FastAPI, Starlette, Litestar...). Websocket без доступа закрывается до accept (403)."""
|
|
452
|
+
|
|
453
|
+
def __init__(self, app, tokens=None, request_url=None, cookie_name=COOKIE_NAME, max_age=COOKIE_MAX_AGE,
|
|
454
|
+
emails=None, secret=None):
|
|
455
|
+
self.app = app
|
|
456
|
+
self.gate = _Gate(tokens, request_url, cookie_name, max_age, emails, secret)
|
|
457
|
+
|
|
458
|
+
async def __call__(self, scope, receive, send):
|
|
459
|
+
if scope["type"] not in ("http", "websocket"): # lifespan и прочее — без проверки
|
|
460
|
+
return await self.app(scope, receive, send)
|
|
461
|
+
gate = self.gate
|
|
462
|
+
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers") or []}
|
|
463
|
+
if scope["type"] == "websocket":
|
|
464
|
+
if gate.cookie_owner(headers.get("cookie", "")) is not None:
|
|
465
|
+
return await self.app(scope, receive, send)
|
|
466
|
+
return await send({"type": "websocket.close", "code": 1008})
|
|
467
|
+
|
|
468
|
+
method = scope.get("method", "GET")
|
|
469
|
+
base = scope.get("root_path", "")
|
|
470
|
+
path = scope.get("path", "") or "/"
|
|
471
|
+
body = b""
|
|
472
|
+
if gate.wants_body(method, path):
|
|
473
|
+
while len(body) < 4096:
|
|
474
|
+
message = await receive()
|
|
475
|
+
body += message.get("body", b"")
|
|
476
|
+
if not message.get("more_body"):
|
|
477
|
+
break
|
|
478
|
+
client = scope.get("client") or ("", 0)
|
|
479
|
+
result = gate.dispatch(
|
|
480
|
+
method, base, path, (base + path).encode("utf-8"),
|
|
481
|
+
parse_qsl(scope.get("query_string", b"").decode("latin-1"), keep_blank_values=True),
|
|
482
|
+
headers.get("cookie", ""), _client_ip(headers.get("x-forwarded-for"), client[0]),
|
|
483
|
+
headers.get("user-agent", ""), headers.get("host", ""),
|
|
484
|
+
_is_https(headers.get("x-forwarded-proto"), scope.get("scheme")), body[:4096],
|
|
485
|
+
headers.get("accept-language", ""))
|
|
486
|
+
if result is None:
|
|
487
|
+
return await self.app(scope, receive, send)
|
|
488
|
+
status, response_headers, payload = result
|
|
489
|
+
await send({"type": "http.response.start", "status": status, "headers": [
|
|
490
|
+
(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in response_headers
|
|
491
|
+
] + [(b"content-length", str(len(payload)).encode("ascii"))]})
|
|
492
|
+
await send({"type": "http.response.body", "body": payload})
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _owner(value, entries):
|
|
496
|
+
value = value.encode("utf-8")
|
|
497
|
+
for name, candidate in entries:
|
|
498
|
+
if hmac.compare_digest(value, candidate.encode("utf-8")):
|
|
499
|
+
return name
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _cookie_value(token):
|
|
504
|
+
# В куке лежит не сам токен, а производное от него: убрали токен из ACCESS_TOKEN — его куки больше не подходят.
|
|
505
|
+
return hmac.new(token.encode("utf-8"), b"magic-link-cookie", hashlib.sha256).hexdigest()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _b64(data):
|
|
509
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _unb64(text):
|
|
513
|
+
try:
|
|
514
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)).decode("utf-8")
|
|
515
|
+
except (ValueError, UnicodeDecodeError) as e:
|
|
516
|
+
raise ValueError(e)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _client_ip(forwarded, remote):
|
|
520
|
+
return (forwarded or remote or "").split(",")[0].strip()
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _is_https(forwarded_proto, scheme):
|
|
524
|
+
return (forwarded_proto or scheme or "http").split(",")[0].strip() == "https"
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _mail_mode():
|
|
528
|
+
"""Чем отправлять письма для входа: "log", "resend", "smtp" или "" — не настроено."""
|
|
529
|
+
if os.environ.get("LOGIN_LINKS_TO_LOG", "").strip().lower() in ("1", "true", "yes"):
|
|
530
|
+
return "log"
|
|
531
|
+
if os.environ.get("RESEND_API_KEY", "").strip():
|
|
532
|
+
return "resend"
|
|
533
|
+
if os.environ.get("SMTP_URL", "").strip():
|
|
534
|
+
return "smtp"
|
|
535
|
+
return ""
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _send_login_mail(email, link, app_name, lang=DEFAULT_LANG):
|
|
539
|
+
mode = _mail_mode()
|
|
540
|
+
if mode == "log":
|
|
541
|
+
_log(_for_owner("[magic-link] sign-in link (%s): %s", "[magic-link] ссылка для входа (%s): %s") % (email, link))
|
|
542
|
+
return
|
|
543
|
+
ui = UI[lang if lang in UI else DEFAULT_LANG]
|
|
544
|
+
body = '<p>%s</p><p><a href="%s">%s</a></p><p style="color:#666">%s</p>' % (
|
|
545
|
+
ui["mail_intro"] % html.escape(app_name), html.escape(link), ui["mail_link"], ui["mail_note"])
|
|
546
|
+
try:
|
|
547
|
+
send_mail(email, ui["subject"] % app_name, ui["mail_text"] % (app_name, link), body)
|
|
548
|
+
_log(_for_owner("[magic-link] sign-in email sent: %s", "[magic-link] письмо для входа отправлено: %s") % email)
|
|
549
|
+
except Exception as e: # письмо — не повод ронять приложение; ключи и пароли в лог не пишем
|
|
550
|
+
_log(_for_owner("[magic-link] could not send the sign-in email to %s: %s",
|
|
551
|
+
"[magic-link] не удалось отправить письмо для входа на %s: %s") % (email, _describe(e)))
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def send_mail(to, subject, text, html_body):
|
|
555
|
+
"""Письмо через Resend (RESEND_API_KEY) или SMTP (SMTP_URL). Ошибка — исключение."""
|
|
556
|
+
sender = os.environ.get("MAIL_FROM", "").strip()
|
|
557
|
+
key = os.environ.get("RESEND_API_KEY", "").strip()
|
|
558
|
+
smtp_url = os.environ.get("SMTP_URL", "").strip()
|
|
559
|
+
if key:
|
|
560
|
+
api = os.environ.get("RESEND_API_URL", "https://api.resend.com").rstrip("/")
|
|
561
|
+
request = urllib.request.Request(api + "/emails", headers={
|
|
562
|
+
"Authorization": "Bearer " + key, "Content-Type": "application/json", "User-Agent": "small-magic-link/1.0",
|
|
563
|
+
}, data=json.dumps({"from": sender or "small <onboarding@resend.dev>", "to": [to], "subject": subject,
|
|
564
|
+
"text": text, "html": html_body}).encode("utf-8"))
|
|
565
|
+
urllib.request.urlopen(request, timeout=15).close()
|
|
566
|
+
elif smtp_url:
|
|
567
|
+
url = urlsplit(smtp_url)
|
|
568
|
+
secure = url.scheme == "smtps"
|
|
569
|
+
message = EmailMessage()
|
|
570
|
+
message["From"] = sender or unquote(url.username or "")
|
|
571
|
+
message["To"] = to
|
|
572
|
+
message["Subject"] = subject
|
|
573
|
+
message.set_content(text)
|
|
574
|
+
message.add_alternative(html_body, subtype="html")
|
|
575
|
+
with (smtplib.SMTP_SSL if secure else smtplib.SMTP)(url.hostname, url.port or (465 if secure else 587),
|
|
576
|
+
timeout=15) as server:
|
|
577
|
+
if not secure:
|
|
578
|
+
server.ehlo()
|
|
579
|
+
if server.has_extn("starttls"):
|
|
580
|
+
server.starttls()
|
|
581
|
+
server.ehlo()
|
|
582
|
+
if url.username:
|
|
583
|
+
server.login(unquote(url.username), unquote(url.password or ""))
|
|
584
|
+
server.send_message(message)
|
|
585
|
+
else:
|
|
586
|
+
raise RuntimeError(_for_owner("email is not set up: set RESEND_API_KEY or SMTP_URL",
|
|
587
|
+
"почта не настроена: задайте RESEND_API_KEY или SMTP_URL"))
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _describe(error):
|
|
591
|
+
if isinstance(error, urllib.error.HTTPError):
|
|
592
|
+
try:
|
|
593
|
+
detail = error.read().decode("utf-8", "replace")[:200]
|
|
594
|
+
except Exception:
|
|
595
|
+
detail = ""
|
|
596
|
+
return "HTTP %d %s" % (error.code, detail)
|
|
597
|
+
return "%s: %s" % (type(error).__name__, str(error)[:200])
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _send_telegram(token, chat, text):
|
|
601
|
+
api = os.environ.get("TELEGRAM_API_URL", "https://api.telegram.org").rstrip("/")
|
|
602
|
+
request = urllib.request.Request("%s/bot%s/sendMessage" % (api, token), headers={"Content-Type": "application/json"},
|
|
603
|
+
data=json.dumps({"chat_id": chat, "text": text}).encode("utf-8"))
|
|
604
|
+
try:
|
|
605
|
+
urllib.request.urlopen(request, timeout=10).close()
|
|
606
|
+
except Exception as e: # уведомление — не повод ронять запрос; токен бота в лог не пишем
|
|
607
|
+
_log(_for_owner("[magic-link] could not send the access request to Telegram: %s",
|
|
608
|
+
"[magic-link] не удалось отправить запрос доступа в Telegram: %s") % type(e).__name__)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _for_owner(en, ru):
|
|
612
|
+
"""Текст для владельца (лог, Telegram) — на его языке, OWNER_LANG. Посетитель видит язык своего браузера."""
|
|
613
|
+
return ru if os.environ.get("OWNER_LANG", "").strip().lower()[:2] == "ru" else en
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _log(message):
|
|
617
|
+
print(message, flush=True)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
REQUEST_FORM = Template(
|
|
621
|
+
'<form method="post" action="$action">'
|
|
622
|
+
'<input name="contact" required maxlength="200" placeholder="$placeholder">'
|
|
623
|
+
'<button class="btn" type="submit">$button</button></form>'
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
LOGIN_FORM = Template(
|
|
627
|
+
'<form method="post" action="$action">'
|
|
628
|
+
'<input name="email" type="email" required maxlength="254" autocomplete="email" placeholder="name@company.com">'
|
|
629
|
+
'<button class="btn" type="submit">$button</button></form>'
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
CONFIRM_FORM = Template(
|
|
633
|
+
'<form method="post" action="$action"><input type="hidden" name="t" value="$token">'
|
|
634
|
+
'<button class="btn" type="submit">$button</button></form>'
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
PAGE = Template("""<!doctype html>
|
|
638
|
+
<html lang="$lang">
|
|
639
|
+
<head>
|
|
640
|
+
<meta charset="utf-8">
|
|
641
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
642
|
+
<meta name="robots" content="noindex">
|
|
643
|
+
<title>$title</title>
|
|
644
|
+
<style>
|
|
645
|
+
:root { color-scheme: light dark; --bg: #f5f5f4; --card: #fff; --text: #1c1917; --muted: #57534e;
|
|
646
|
+
--border: #e7e5e4; --accent: #2563eb; }
|
|
647
|
+
@media (prefers-color-scheme: dark) {
|
|
648
|
+
:root { --bg: #0c0a09; --card: #1c1917; --text: #f5f5f4; --muted: #a8a29e; --border: #292524; --accent: #3b82f6; }
|
|
649
|
+
}
|
|
650
|
+
* { box-sizing: border-box; }
|
|
651
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 16px;
|
|
652
|
+
background: var(--bg); color: var(--text); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
653
|
+
main { width: 100%; max-width: 400px; padding: 32px; text-align: center;
|
|
654
|
+
background: var(--card); border: 1px solid var(--border); border-radius: 12px; }
|
|
655
|
+
.icon { font-size: 32px; line-height: 1; margin-bottom: 12px; }
|
|
656
|
+
h1 { margin: 0 0 8px; font-size: 20px; }
|
|
657
|
+
p { margin: 0 0 24px; color: var(--muted); overflow-wrap: anywhere; }
|
|
658
|
+
input { width: 100%; margin-bottom: 12px; padding: 10px 12px; font: inherit; color: inherit;
|
|
659
|
+
background: transparent; border: 1px solid var(--border); border-radius: 8px; }
|
|
660
|
+
.btn { display: block; width: 100%; padding: 10px 16px; font: inherit; font-weight: 600; color: #fff;
|
|
661
|
+
background: var(--accent); border: 0; border-radius: 8px; text-decoration: none; cursor: pointer; }
|
|
662
|
+
.btn:hover { filter: brightness(1.1); }
|
|
663
|
+
.link { color: var(--accent); }
|
|
664
|
+
</style>
|
|
665
|
+
</head>
|
|
666
|
+
<body>
|
|
667
|
+
<main>
|
|
668
|
+
<div class="icon" aria-hidden="true">$icon</div>
|
|
669
|
+
<h1>$title</h1>
|
|
670
|
+
<p>$text</p>
|
|
671
|
+
$action
|
|
672
|
+
</main>
|
|
673
|
+
</body>
|
|
674
|
+
</html>
|
|
675
|
+
""")
|