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.
Files changed (41) hide show
  1. small_cli/__init__.py +1 -0
  2. small_cli/__main__.py +3 -0
  3. small_cli/access.py +114 -0
  4. small_cli/archive.py +127 -0
  5. small_cli/cli.py +76 -0
  6. small_cli/commands/__init__.py +8 -0
  7. small_cli/commands/access.py +167 -0
  8. small_cli/commands/dashboard.py +49 -0
  9. small_cli/commands/db.py +154 -0
  10. small_cli/commands/deploy.py +248 -0
  11. small_cli/commands/destroy.py +63 -0
  12. small_cli/commands/domain.py +123 -0
  13. small_cli/commands/env.py +151 -0
  14. small_cli/commands/init.py +86 -0
  15. small_cli/commands/link.py +53 -0
  16. small_cli/commands/logs.py +59 -0
  17. small_cli/commands/mail.py +203 -0
  18. small_cli/commands/notify.py +147 -0
  19. small_cli/commands/sleep.py +61 -0
  20. small_cli/common.py +92 -0
  21. small_cli/config.py +82 -0
  22. small_cli/dashboard.py +648 -0
  23. small_cli/i18n.py +55 -0
  24. small_cli/railway.py +369 -0
  25. small_cli/scaffold.py +81 -0
  26. small_cli/templates/fastapi/magic_link.py +675 -0
  27. small_cli/templates/fastapi/main.py +67 -0
  28. small_cli/templates/fastapi/requirements.txt +2 -0
  29. small_cli/templates/node-http/magic-link.js +509 -0
  30. small_cli/templates/node-http/package.json +11 -0
  31. small_cli/templates/node-http/server.js +33 -0
  32. small_cli/templates/python-http/app.py +47 -0
  33. small_cli/templates/python-http/magic_link.py +675 -0
  34. small_cli/templates/streamlit/app.py +181 -0
  35. small_cli/templates/streamlit/requirements.txt +1 -0
  36. small_software-0.6.0.dist-info/METADATA +559 -0
  37. small_software-0.6.0.dist-info/RECORD +41 -0
  38. small_software-0.6.0.dist-info/WHEEL +5 -0
  39. small_software-0.6.0.dist-info/entry_points.txt +2 -0
  40. small_software-0.6.0.dist-info/licenses/LICENSE +21 -0
  41. 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": ("&#128274;", "Доступ по приглашению",
75
+ "Эта страница открывается только по ссылке-приглашению от владельца."),
76
+ "invalid": ("&#128274;", "Ссылка недействительна",
77
+ "Ссылка устарела или скопирована не полностью. Запросите новую."),
78
+ "sent": ("&#9989;", "Запрос отправлен", "Владелец увидит запрос и пришлёт вам ссылку-приглашение."),
79
+ "login": ("&#128274;", "Вход по рабочей почте",
80
+ "Приложение открывается только для приглашённых. Введите рабочую почту — пришлём ссылку для входа."),
81
+ "check": ("&#9993;&#65039;", "Проверьте почту",
82
+ "Если у адреса есть доступ, ссылка для входа уже в почте: она действует 15 минут. "
83
+ "Если доступа нет, владелец получит ваш запрос."),
84
+ "bad_email": ("&#9888;&#65039;", "Проверьте адрес", "Похоже, в адресе почты ошибка. Введите его ещё раз."),
85
+ "slow": ("&#8987;", "Слишком много попыток", "Подождите 15 минут и попробуйте снова."),
86
+ "expired": ("&#8987;", "Ссылка для входа устарела",
87
+ "Ссылка действует 15 минут и только один раз. Введите почту — пришлём новую."),
88
+ "confirm": ("&#128274;", "Вход в приложение", "Нажмите кнопку, чтобы войти как %s."),
89
+ "logout": ("&#128075;", "Вы вышли", "Чтобы войти снова, введите рабочую почту."),
90
+ },
91
+ "en": {
92
+ "forbidden": ("&#128274;", "Invitation only", "This page opens only with an invitation link from its owner."),
93
+ "invalid": ("&#128274;", "Invalid link", "The link has expired or was copied incompletely. Ask for a new one."),
94
+ "sent": ("&#9989;", "Request sent", "The owner will see your request and send you an invitation link."),
95
+ "login": ("&#128274;", "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": ("&#9993;&#65039;", "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": ("&#9888;&#65039;", "Check the address", "This email address looks wrong. Please enter it again."),
101
+ "slow": ("&#8987;", "Too many attempts", "Please wait 15 minutes and try again."),
102
+ "expired": ("&#8987;", "Sign-in link expired",
103
+ "The link works for 15 minutes and only once. Enter your email to get a new one."),
104
+ "confirm": ("&#128274;", "Sign in", "Click the button to sign in as %s."),
105
+ "logout": ("&#128075;", "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
+ """)