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,67 @@
|
|
|
1
|
+
"""A FastAPI app created by `small init --template fastapi`. Deploy it with `small deploy`.
|
|
2
|
+
|
|
3
|
+
Railway (Railpack) finds fastapi and uvicorn in requirements.txt and runs `uvicorn main:app` on $PORT by itself.
|
|
4
|
+
Locally: `python main.py` (or `uvicorn main:app --reload`).
|
|
5
|
+
Who gets in is decided by MagicLinkASGI from magic_link.py: people you invite by work email
|
|
6
|
+
(`small access add ivan@company.com`) or with a personal invite link (`small access add bob`).
|
|
7
|
+
The API docs (/docs) and websockets are protected too.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
from fastapi import FastAPI
|
|
16
|
+
from fastapi.responses import HTMLResponse
|
|
17
|
+
|
|
18
|
+
from magic_link import MagicLinkASGI
|
|
19
|
+
|
|
20
|
+
app = FastAPI(title="small app", version="1.0.0")
|
|
21
|
+
app.add_middleware(MagicLinkASGI)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HideToken(logging.Filter):
|
|
25
|
+
"""uvicorn writes the full URL to its access log: hide ?token=... and ?t=... (sign-in link) so they never reach
|
|
26
|
+
the Railway logs."""
|
|
27
|
+
|
|
28
|
+
def filter(self, record):
|
|
29
|
+
if isinstance(record.args, tuple):
|
|
30
|
+
record.args = tuple(re.sub(r"\b(token|t)=[^&\s]*", r"\1=***", a) if isinstance(a, str) else a
|
|
31
|
+
for a in record.args)
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
logging.getLogger("uvicorn.access").addFilter(HideToken())
|
|
36
|
+
|
|
37
|
+
PAGE = """<!doctype html>
|
|
38
|
+
<html lang="en">
|
|
39
|
+
<head>
|
|
40
|
+
<meta charset="utf-8">
|
|
41
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
42
|
+
<title>small app</title>
|
|
43
|
+
</head>
|
|
44
|
+
<body style="font-family: system-ui, sans-serif; text-align: center; padding: 48px 16px;">
|
|
45
|
+
<h1>🚀 small + FastAPI</h1>
|
|
46
|
+
<p>This app was created by <code>small init --template fastapi</code>.
|
|
47
|
+
Edit <code>main.py</code> and run <code>small deploy</code>.</p>
|
|
48
|
+
<p><a href="/docs">API docs</a> · <a href="/api/hello?name=small">/api/hello</a></p>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|
55
|
+
def index():
|
|
56
|
+
return PAGE
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.get("/api/hello")
|
|
60
|
+
def hello(name: str = "world"):
|
|
61
|
+
return {"message": "Hello, %s!" % name, "time": datetime.now(timezone.utc).isoformat(timespec="seconds")}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
import uvicorn
|
|
66
|
+
|
|
67
|
+
uvicorn.run(app, host=os.environ.get("HOST", "0.0.0.0"), port=int(os.environ.get("PORT", 8000)))
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Защита Node.js-приложения: вход по рабочей почте или по ссылке-приглашению. Один файл, без зависимостей, Node 18+.
|
|
4
|
+
*
|
|
5
|
+
* const magicLink = require('./magic-link');
|
|
6
|
+
* app.use(magicLink()); // Express / Connect
|
|
7
|
+
*
|
|
8
|
+
* const guard = magicLink(); // чистый http
|
|
9
|
+
* http.createServer((req, res) => guard(req, res, () => handler(req, res)));
|
|
10
|
+
*
|
|
11
|
+
* Два способа пустить человека:
|
|
12
|
+
* - Вход по почте — как доступ к Google Doc. Владелец перечисляет адреса в ACCESS_EMAILS. Человек вводит свою
|
|
13
|
+
* почту и получает письмо со ссылкой для входа (15 минут, один раз); браузер помнит вход 30 дней.
|
|
14
|
+
* Незнакомый адрес — это запрос доступа.
|
|
15
|
+
* - Ссылка-приглашение https://<app>/?token=<токен> (ACCESS_TOKEN): ставит куку и перенаправляет без токена.
|
|
16
|
+
* Без доступа — страница 403: форма входа по почте (если задан ACCESS_EMAILS) или кнопка «Запросить доступ».
|
|
17
|
+
*
|
|
18
|
+
* Переменные окружения:
|
|
19
|
+
* ACCESS_EMAILS адреса через запятую; "@company.com" — весь домен. Убрали адрес — вход перестаёт работать сразу.
|
|
20
|
+
* SESSION_SECRET ключ подписи ссылок и сессий (small создаёт сам). Не задан — случайный на каждый запуск.
|
|
21
|
+
* RESEND_API_KEY, MAIL_FROM письма через Resend (HTTPS). SMTP в Node-версии нет: используйте Resend.
|
|
22
|
+
* LOGIN_LINKS_TO_LOG 1 — не слать письма, а писать ссылку для входа в лог. Только для проверки.
|
|
23
|
+
* PUBLIC_URL адрес приложения для ссылок в письмах; иначе RAILWAY_PUBLIC_DOMAIN; локально — localhost.
|
|
24
|
+
* ACCESS_TOKEN токены ссылок-приглашений через запятую, можно с именами: "alice:токен1,bob:токен2".
|
|
25
|
+
* ACCESS_REQUEST_URL куда ведёт кнопка «Запросить доступ», если нет входа по почте (mailto:..., https://t.me/...).
|
|
26
|
+
* TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID запросы доступа приходят владельцу в Telegram (small notify telegram).
|
|
27
|
+
* ACCESS_LANG язык страниц и писем: ru, en или auto (по умолчанию — по заголовку Accept-Language).
|
|
28
|
+
* OWNER_LANG язык сообщений для владельца — строк лога и уведомлений в Telegram: en (по умолчанию) или ru.
|
|
29
|
+
* small deploy задаёт его по языку small.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const crypto = require('node:crypto');
|
|
33
|
+
|
|
34
|
+
const COOKIE_NAME = 'small_access';
|
|
35
|
+
const COOKIE_MAX_AGE = 30 * 24 * 3600;
|
|
36
|
+
const LINK_TTL = 15 * 60;
|
|
37
|
+
const REQUEST_PATH = '/_access/request';
|
|
38
|
+
const LOGIN_PATH = '/_access/login';
|
|
39
|
+
const VERIFY_PATH = '/_access/verify';
|
|
40
|
+
const LOGOUT_PATH = '/_access/logout';
|
|
41
|
+
// Сколько писем для входа можно запросить за 15 минут: на один адрес и с одного IP.
|
|
42
|
+
const LIMIT_PER_EMAIL = 3;
|
|
43
|
+
const LIMIT_PER_IP = 10;
|
|
44
|
+
const LIMIT_WINDOW = 15 * 60 * 1000;
|
|
45
|
+
|
|
46
|
+
const EMAIL_RE = /^[^@\s,;<>"']+@[^@\s,;<>"']+\.[^@\s,;<>"']+$/;
|
|
47
|
+
const DOMAIN_RE = /^@[^@\s,;<>"']+\.[^@\s,;<>"']+$/;
|
|
48
|
+
|
|
49
|
+
const LANGS = ['ru', 'en'];
|
|
50
|
+
const DEFAULT_LANG = 'ru'; // если браузер не сказал, какой язык предпочитает
|
|
51
|
+
|
|
52
|
+
const MESSAGES = {
|
|
53
|
+
ru: {
|
|
54
|
+
forbidden: ['🔒', 'Доступ по приглашению', 'Эта страница открывается только по ссылке-приглашению от владельца.'],
|
|
55
|
+
invalid: ['🔒', 'Ссылка недействительна', 'Ссылка устарела или скопирована не полностью. Запросите новую.'],
|
|
56
|
+
sent: ['✅', 'Запрос отправлен', 'Владелец увидит запрос и пришлёт вам ссылку-приглашение.'],
|
|
57
|
+
login: ['🔒', 'Вход по рабочей почте',
|
|
58
|
+
'Приложение открывается только для приглашённых. Введите рабочую почту — пришлём ссылку для входа.'],
|
|
59
|
+
check: ['✉️', 'Проверьте почту',
|
|
60
|
+
'Если у адреса есть доступ, ссылка для входа уже в почте: она действует 15 минут. '
|
|
61
|
+
+ 'Если доступа нет, владелец получит ваш запрос.'],
|
|
62
|
+
bad_email: ['⚠️', 'Проверьте адрес', 'Похоже, в адресе почты ошибка. Введите его ещё раз.'],
|
|
63
|
+
slow: ['⌛', 'Слишком много попыток', 'Подождите 15 минут и попробуйте снова.'],
|
|
64
|
+
expired: ['⌛', 'Ссылка для входа устарела', 'Ссылка действует 15 минут и только один раз. Введите почту — пришлём новую.'],
|
|
65
|
+
confirm: ['🔒', 'Вход в приложение', 'Нажмите кнопку, чтобы войти как %s.'],
|
|
66
|
+
logout: ['👋', 'Вы вышли', 'Чтобы войти снова, введите рабочую почту.'],
|
|
67
|
+
},
|
|
68
|
+
en: {
|
|
69
|
+
forbidden: ['🔒', 'Invitation only', 'This page opens only with an invitation link from its owner.'],
|
|
70
|
+
invalid: ['🔒', 'Invalid link', 'The link has expired or was copied incompletely. Ask for a new one.'],
|
|
71
|
+
sent: ['✅', 'Request sent', 'The owner will see your request and send you an invitation link.'],
|
|
72
|
+
login: ['🔒', 'Sign in with your work email',
|
|
73
|
+
"This app is for invited people only. Enter your work email and we'll send you a sign-in link."],
|
|
74
|
+
check: ['✉️', 'Check your email',
|
|
75
|
+
'If this address has access, a sign-in link is already in your inbox. It works for 15 minutes. '
|
|
76
|
+
+ "If it doesn't, the owner will get your request."],
|
|
77
|
+
bad_email: ['⚠️', 'Check the address', 'This email address looks wrong. Please enter it again.'],
|
|
78
|
+
slow: ['⌛', 'Too many attempts', 'Please wait 15 minutes and try again.'],
|
|
79
|
+
expired: ['⌛', 'Sign-in link expired', 'The link works for 15 minutes and only once. Enter your email to get a new one.'],
|
|
80
|
+
confirm: ['🔒', 'Sign in', 'Click the button to sign in as %s.'],
|
|
81
|
+
logout: ['👋', 'Signed out', 'To sign in again, enter your work email.'],
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const UI = {
|
|
86
|
+
ru: {
|
|
87
|
+
request: 'Запросить доступ', contact: 'Email или Telegram для ответа', getLink: 'Получить ссылку для входа',
|
|
88
|
+
signIn: 'Войти', other: 'Ввести другой адрес', subject: (app) => `Вход в ${app}`,
|
|
89
|
+
mailText: (app, link) => `Ссылка для входа в ${app}:\n${link}\n\nОна действует 15 минут и только один раз. `
|
|
90
|
+
+ 'Если вы не запрашивали вход, просто удалите это письмо.\n',
|
|
91
|
+
mailIntro: (app) => `Ссылка для входа в <b>${app}</b>:`,
|
|
92
|
+
mailNote: 'Она действует 15 минут и только один раз. Если вы не запрашивали вход, просто удалите это письмо.',
|
|
93
|
+
},
|
|
94
|
+
en: {
|
|
95
|
+
request: 'Request access', contact: 'Email or Telegram to reach you', getLink: 'Email me a sign-in link',
|
|
96
|
+
signIn: 'Sign in', other: 'Use a different address', subject: (app) => `Sign in to ${app}`,
|
|
97
|
+
mailText: (app, link) => `Sign-in link for ${app}:\n${link}\n\nIt works for 15 minutes and only once. `
|
|
98
|
+
+ "If you didn't ask to sign in, just delete this email.\n",
|
|
99
|
+
mailIntro: (app) => `Sign-in link for <b>${app}</b>:`,
|
|
100
|
+
mailNote: "It works for 15 minutes and only once. If you didn't ask to sign in, just delete this email.",
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Язык страницы: ACCESS_LANG (ru/en), иначе самый предпочтительный из ru/en в Accept-Language.
|
|
105
|
+
function pickLang(acceptLanguage, setting = 'auto') {
|
|
106
|
+
if (LANGS.includes(setting)) return setting;
|
|
107
|
+
if (!String(acceptLanguage || '').trim()) return DEFAULT_LANG;
|
|
108
|
+
let best = 'en'; // браузер просит другой язык (de, fr…) — английский понятнее, чем русский
|
|
109
|
+
let bestQ = -1;
|
|
110
|
+
for (const part of String(acceptLanguage).split(',')) {
|
|
111
|
+
const [tag, ...params] = part.trim().split(';');
|
|
112
|
+
let base = tag.trim().toLowerCase().split('-')[0];
|
|
113
|
+
if (base === '*') base = DEFAULT_LANG; // «любой язык» (так по умолчанию пишет fetch в Node)
|
|
114
|
+
const qParam = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
|
|
115
|
+
const q = qParam ? Number(qParam.slice(2)) || 0 : 1;
|
|
116
|
+
if (LANGS.includes(base) && q > bestQ) { best = base; bestQ = q; }
|
|
117
|
+
}
|
|
118
|
+
return best;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function magicLink(options = {}) {
|
|
122
|
+
const entries = parseTokens(options.tokens ?? process.env.ACCESS_TOKEN ?? '');
|
|
123
|
+
const tokens = entries.map((e) => [e.name, e.token]);
|
|
124
|
+
const cookies = entries.map((e) => [e.name, cookieValue(e.token)]);
|
|
125
|
+
const emails = parseEmails(options.emails ?? process.env.ACCESS_EMAILS ?? '');
|
|
126
|
+
let secret = String(options.secret ?? process.env.SESSION_SECRET ?? '').trim();
|
|
127
|
+
if (emails.size && !secret) {
|
|
128
|
+
console.log(ownerText('[magic-link] SESSION_SECRET is not set: everyone will have to sign in again after a restart',
|
|
129
|
+
'[magic-link] SESSION_SECRET не задан: после перезапуска приложения всем придётся войти заново'));
|
|
130
|
+
}
|
|
131
|
+
secret = secret || crypto.randomBytes(32).toString('hex');
|
|
132
|
+
const requestUrl = options.requestUrl ?? process.env.ACCESS_REQUEST_URL ?? '';
|
|
133
|
+
const langSetting = String(options.lang ?? process.env.ACCESS_LANG ?? 'auto').trim().toLowerCase();
|
|
134
|
+
const langOf = (req) => pickLang(req.headers['accept-language'], langSetting);
|
|
135
|
+
const cookieName = options.cookieName ?? COOKIE_NAME;
|
|
136
|
+
const maxAge = options.maxAge ?? COOKIE_MAX_AGE;
|
|
137
|
+
const telegramToken = String(process.env.TELEGRAM_BOT_TOKEN || '').trim();
|
|
138
|
+
const telegramChat = String(process.env.TELEGRAM_CHAT_ID || '').trim();
|
|
139
|
+
const usedLinks = new Map(); // одноразовые ссылки для входа: nonce -> срок (мс)
|
|
140
|
+
const attempts = new Map(); // ограничение частоты писем: ключ -> [время запросов]
|
|
141
|
+
if (!entries.length && !emails.size) {
|
|
142
|
+
console.log(ownerText('[magic-link] neither ACCESS_TOKEN nor ACCESS_EMAILS is set: nobody can get in',
|
|
143
|
+
'[magic-link] ACCESS_TOKEN и ACCESS_EMAILS не заданы: доступ закрыт для всех'));
|
|
144
|
+
}
|
|
145
|
+
if (emails.size && !mailMode()) {
|
|
146
|
+
console.log(ownerText('[magic-link] email is not set up (RESEND_API_KEY): sign-in emails will not be sent. Set it up: small mail',
|
|
147
|
+
'[magic-link] почта не настроена (RESEND_API_KEY): письма для входа не уйдут. Подключить: small mail'));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const base = (req) => req.baseUrl || '';
|
|
151
|
+
const clientIp = (req) => String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
|
|
152
|
+
const isHttps = (req) => String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https'
|
|
153
|
+
|| Boolean(req.socket.encrypted);
|
|
154
|
+
const cookie = (value, https) => `${cookieName}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${https ? '; Secure' : ''}`;
|
|
155
|
+
|
|
156
|
+
function page(req, res, status, kind, { email = '', token = '', headers = {} } = {}) {
|
|
157
|
+
if (kind === 'forbidden' && emails.size) kind = 'login';
|
|
158
|
+
const lang = langOf(req);
|
|
159
|
+
const ui = UI[lang];
|
|
160
|
+
const [icon, title, rawText] = MESSAGES[lang][kind];
|
|
161
|
+
const text = kind === 'confirm' ? rawText.replace('%s', email) : rawText;
|
|
162
|
+
let action = '';
|
|
163
|
+
if (kind === 'check') action = `<a class="link" href="${escapeHtml(base(req))}/">${ui.other}</a>`;
|
|
164
|
+
else if (kind === 'confirm') action = confirmForm(escapeHtml(base(req) + VERIFY_PATH), escapeHtml(token), ui.signIn);
|
|
165
|
+
else if (kind === 'sent') action = '';
|
|
166
|
+
else if (emails.size) action = loginForm(escapeHtml(base(req) + LOGIN_PATH), ui.getLink);
|
|
167
|
+
else if (requestUrl) action = `<a class="btn" href="${escapeHtml(requestUrl)}">${ui.request}</a>`;
|
|
168
|
+
else action = requestForm(escapeHtml(base(req) + REQUEST_PATH), ui.contact, ui.request);
|
|
169
|
+
const body = renderPage(lang, icon, escapeHtml(title), escapeHtml(text), action);
|
|
170
|
+
res.writeHead(status, {
|
|
171
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
172
|
+
'Content-Length': Buffer.byteLength(body),
|
|
173
|
+
'Cache-Control': 'no-store',
|
|
174
|
+
'Referrer-Policy': 'no-referrer',
|
|
175
|
+
'Content-Language': lang,
|
|
176
|
+
...headers,
|
|
177
|
+
});
|
|
178
|
+
res.end(body);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Поля формы: из req.body (express.urlencoded()) или прочитав поток сами.
|
|
182
|
+
function readForm(req, callback) {
|
|
183
|
+
if (req.body !== undefined || req.readableEnded) return callback(new URLSearchParams(req.body || {}));
|
|
184
|
+
let body = '';
|
|
185
|
+
req.setEncoding('utf8');
|
|
186
|
+
req.on('data', (chunk) => { if (body.length < 4096) body += chunk; });
|
|
187
|
+
req.on('end', () => callback(new URLSearchParams(body.slice(0, 4096))));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function notifyRequest(req, contact) {
|
|
191
|
+
const entry = { contact: String(contact || '').slice(0, 200), ip: clientIp(req), ua: String(req.headers['user-agent'] || '').slice(0, 200) };
|
|
192
|
+
// JSON.stringify экранирует переводы строк: пользователь не сможет подделать строки лога.
|
|
193
|
+
console.log('[access-request] ' + JSON.stringify(entry));
|
|
194
|
+
if (!(telegramToken && telegramChat)) return;
|
|
195
|
+
// Без await: пользователь не ждёт Telegram. Без parse_mode — текст из формы ничего не разметит.
|
|
196
|
+
const api = String(process.env.TELEGRAM_API_URL || 'https://api.telegram.org').replace(/\/+$/, '');
|
|
197
|
+
const hint = 'small access add ' + (EMAIL_RE.test(entry.contact) ? entry.contact : ownerText('NAME', 'ИМЯ'));
|
|
198
|
+
const host = req.headers.host || ownerText('the app', 'приложению');
|
|
199
|
+
const text = ownerText(
|
|
200
|
+
`🔑 Access request to ${host}\nContact: ${entry.contact || '—'}\nIP: ${entry.ip || '—'}\n\nGrant access: ${hint}`,
|
|
201
|
+
`🔑 Запрос доступа к ${host}\nКонтакт: ${entry.contact || '—'}\nIP: ${entry.ip || '—'}\n\nВыдать доступ: ${hint}`);
|
|
202
|
+
fetch(`${api}/bot${telegramToken}/sendMessage`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'Content-Type': 'application/json' },
|
|
205
|
+
body: JSON.stringify({ chat_id: telegramChat, text }),
|
|
206
|
+
}).catch((e) => console.log(ownerText('[magic-link] could not send the access request to Telegram: ',
|
|
207
|
+
'[magic-link] не удалось отправить запрос доступа в Telegram: ') + e.name));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function emailAllowed(email) {
|
|
211
|
+
email = String(email).toLowerCase();
|
|
212
|
+
return emails.has(email) || emails.has('@' + email.slice(email.lastIndexOf('@') + 1));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// purpose разводит ссылку для входа и сессию: одну нельзя выдать за другую.
|
|
216
|
+
const mac = (purpose, payload) => crypto.createHmac('sha256', secret).update(`${purpose}|${payload}`).digest('base64url');
|
|
217
|
+
const sign = (purpose, data) => {
|
|
218
|
+
const payload = Buffer.from(JSON.stringify(data)).toString('base64url');
|
|
219
|
+
return `${payload}.${mac(purpose, payload)}`;
|
|
220
|
+
};
|
|
221
|
+
function unsign(purpose, value) {
|
|
222
|
+
const [payload, signature = ''] = String(value || '').split('.');
|
|
223
|
+
if (!payload || !safeEqual(signature, mac(purpose, payload))) return null;
|
|
224
|
+
try {
|
|
225
|
+
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
226
|
+
return data && Number.isInteger(data.x) && data.x >= Date.now() / 1000 ? data : null;
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function cookieOwner(req) {
|
|
233
|
+
const value = readCookie(req.headers.cookie, cookieName);
|
|
234
|
+
if (value && value.startsWith('e.')) {
|
|
235
|
+
const data = unsign('session', value.slice(2));
|
|
236
|
+
return data && emailAllowed(data.e) ? data.e : null;
|
|
237
|
+
}
|
|
238
|
+
return owner(value, cookies);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function allow(key, limit) {
|
|
242
|
+
const now = Date.now();
|
|
243
|
+
const recent = (attempts.get(key) || []).filter((t) => t > now - LIMIT_WINDOW);
|
|
244
|
+
if (attempts.size > 10000) for (const [k, v] of attempts) if (v[v.length - 1] <= now - LIMIT_WINDOW) attempts.delete(k);
|
|
245
|
+
if (recent.length >= limit) {
|
|
246
|
+
attempts.set(key, recent);
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
attempts.set(key, [...recent, now]);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Адрес приложения для ссылки в письме. Заголовку Host верим только локально: иначе злоумышленник подставил бы
|
|
254
|
+
// свой домен, и ссылка для входа из настоящего письма ушла бы к нему.
|
|
255
|
+
function publicUrl(req) {
|
|
256
|
+
const explicit = String(process.env.PUBLIC_URL || '').trim().replace(/\/+$/, '');
|
|
257
|
+
if (explicit) return explicit;
|
|
258
|
+
const domain = String(process.env.RAILWAY_PUBLIC_DOMAIN || '').trim();
|
|
259
|
+
if (domain) return `https://${domain}${base(req)}`;
|
|
260
|
+
const host = String(req.headers.host || '');
|
|
261
|
+
const hostname = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
262
|
+
if (['localhost', '127.0.0.1', '::1'].includes(hostname) || hostname.endsWith('.localhost')) {
|
|
263
|
+
return `${isHttps(req) ? 'https' : 'http'}://${host}${base(req)}`;
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function loginRequest(req, res) {
|
|
269
|
+
readForm(req, (form) => {
|
|
270
|
+
const email = String(form.get('email') || '').trim().toLowerCase().slice(0, 254);
|
|
271
|
+
if (!EMAIL_RE.test(email)) return page(req, res, 400, 'bad_email');
|
|
272
|
+
if (!(allow('ip:' + clientIp(req), LIMIT_PER_IP) && allow('mail:' + email, LIMIT_PER_EMAIL))) return page(req, res, 429, 'slow');
|
|
273
|
+
if (emailAllowed(email)) {
|
|
274
|
+
const appUrl = publicUrl(req);
|
|
275
|
+
if (!appUrl) {
|
|
276
|
+
console.log(ownerText("[magic-link] sign-in email not sent: the app's address is unknown. Set PUBLIC_URL",
|
|
277
|
+
'[magic-link] письмо для входа не отправлено: неизвестен адрес приложения. Задайте PUBLIC_URL'));
|
|
278
|
+
} else {
|
|
279
|
+
const token = sign('login', { e: email, x: Math.floor(Date.now() / 1000) + LINK_TTL, n: crypto.randomBytes(9).toString('base64url') });
|
|
280
|
+
// Без await: ответ не ждёт почту и одинаков для известных и неизвестных адресов.
|
|
281
|
+
magicLink.sendLoginMail(email, `${appUrl}${VERIFY_PATH}?t=${encodeURIComponent(token)}`, new URL(appUrl).host,
|
|
282
|
+
langOf(req));
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
notifyRequest(req, email);
|
|
286
|
+
}
|
|
287
|
+
return page(req, res, 200, 'check');
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function verify(req, res, url) {
|
|
292
|
+
const finish = (token) => {
|
|
293
|
+
const data = unsign('login', token);
|
|
294
|
+
const fresh = data && emailAllowed(data.e) && !usedLinks.has(data.n);
|
|
295
|
+
if (!fresh) return page(req, res, 403, 'expired');
|
|
296
|
+
// Почтовые сканеры сами открывают ссылки из писем. Вход — только по кнопке, иначе сканер «потратит» ссылку.
|
|
297
|
+
if (req.method !== 'POST') return page(req, res, 200, 'confirm', { email: data.e, token });
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
for (const [nonce, expires] of usedLinks) if (expires < now) usedLinks.delete(nonce);
|
|
300
|
+
usedLinks.set(data.n, data.x * 1000);
|
|
301
|
+
console.log(ownerText(`[magic-link] signed in by email (${data.e}), ip=${clientIp(req)}`,
|
|
302
|
+
`[magic-link] вход по почте (${data.e}), ip=${clientIp(req)}`));
|
|
303
|
+
const session = 'e.' + sign('session', { e: data.e, x: Math.floor(now / 1000) + maxAge });
|
|
304
|
+
res.writeHead(302, { Location: base(req) + '/', 'Set-Cookie': cookie(session, isHttps(req)), 'Cache-Control': 'no-store' });
|
|
305
|
+
return res.end();
|
|
306
|
+
};
|
|
307
|
+
if (req.method === 'POST') return readForm(req, (form) => finish(form.get('t')));
|
|
308
|
+
return finish(url.searchParams.get('t'));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return function magicLinkMiddleware(req, res, next) {
|
|
312
|
+
const url = new URL(req.url, 'http://localhost');
|
|
313
|
+
const path = url.pathname;
|
|
314
|
+
if (req.method === 'POST' && path === REQUEST_PATH && !requestUrl) {
|
|
315
|
+
return readForm(req, (form) => {
|
|
316
|
+
notifyRequest(req, form.get('contact'));
|
|
317
|
+
page(req, res, 200, 'sent');
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (emails.size && path === LOGIN_PATH && req.method === 'POST') return loginRequest(req, res);
|
|
321
|
+
if (emails.size && path === VERIFY_PATH) return verify(req, res, url);
|
|
322
|
+
if (path === LOGOUT_PATH) {
|
|
323
|
+
return page(req, res, 200, emails.size ? 'logout' : 'forbidden',
|
|
324
|
+
{ headers: { 'Set-Cookie': `${cookieName}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax` } });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const token = url.searchParams.get('token');
|
|
328
|
+
if (token !== null) {
|
|
329
|
+
const name = owner(token, tokens);
|
|
330
|
+
if (name === null) return page(req, res, 403, 'invalid');
|
|
331
|
+
console.log(ownerText('[magic-link] signed in by link', '[magic-link] вход по ссылке')
|
|
332
|
+
+ (name ? ` (${name})` : '') + ', ip=' + clientIp(req));
|
|
333
|
+
// Убираем токен из адреса, чтобы он не оставался в истории браузера и не утекал через Referer.
|
|
334
|
+
// Ведущие слэши схлопываем: Location "//host" был бы открытым редиректом.
|
|
335
|
+
const full = new URL(req.originalUrl || req.url, 'http://localhost');
|
|
336
|
+
full.searchParams.delete('token');
|
|
337
|
+
res.writeHead(302, {
|
|
338
|
+
Location: '/' + full.pathname.replace(/^\/+/, '') + full.search,
|
|
339
|
+
'Set-Cookie': cookie(cookieValue(token), isHttps(req)),
|
|
340
|
+
'Cache-Control': 'no-store',
|
|
341
|
+
});
|
|
342
|
+
return res.end();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (cookieOwner(req) !== null) return next();
|
|
346
|
+
return page(req, res, 403, 'forbidden');
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// "alice:tok1, tok2" -> [{name: "alice", token: "tok1"}, {name: "", token: "tok2"}]. В токене не должно быть ":".
|
|
351
|
+
function parseTokens(raw) {
|
|
352
|
+
const entries = [];
|
|
353
|
+
for (const item of Array.isArray(raw) ? raw : String(raw).split(',')) {
|
|
354
|
+
const text = String(item).trim();
|
|
355
|
+
const colon = text.lastIndexOf(':');
|
|
356
|
+
const token = text.slice(colon + 1).trim();
|
|
357
|
+
if (token) entries.push({ name: colon >= 0 ? text.slice(0, colon).trim() : '', token });
|
|
358
|
+
}
|
|
359
|
+
return entries;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// "Ivan@Company.com, @company.com, мусор" -> Set {"ivan@company.com", "@company.com"}: адреса и домены, без регистра.
|
|
363
|
+
function parseEmails(raw) {
|
|
364
|
+
const found = new Set();
|
|
365
|
+
for (const item of Array.isArray(raw) ? raw : String(raw).split(',')) {
|
|
366
|
+
const text = String(item).trim().toLowerCase();
|
|
367
|
+
if (EMAIL_RE.test(text) || DOMAIN_RE.test(text)) found.add(text);
|
|
368
|
+
}
|
|
369
|
+
return found;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Чем отправлять письма для входа: "log", "resend" или "" — не настроено.
|
|
373
|
+
function mailMode() {
|
|
374
|
+
if (['1', 'true', 'yes'].includes(String(process.env.LOGIN_LINKS_TO_LOG || '').trim().toLowerCase())) return 'log';
|
|
375
|
+
if (String(process.env.RESEND_API_KEY || '').trim()) return 'resend';
|
|
376
|
+
return '';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function sendLoginMail(email, link, appName, lang = DEFAULT_LANG) {
|
|
380
|
+
if (mailMode() === 'log') {
|
|
381
|
+
console.log(ownerText(`[magic-link] sign-in link (${email}): ${link}`, `[magic-link] ссылка для входа (${email}): ${link}`));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const ui = UI[LANGS.includes(lang) ? lang : DEFAULT_LANG];
|
|
385
|
+
const key = String(process.env.RESEND_API_KEY || '').trim();
|
|
386
|
+
try {
|
|
387
|
+
if (!key) throw new Error(ownerText('email is not set up: set RESEND_API_KEY', 'почта не настроена: задайте RESEND_API_KEY'));
|
|
388
|
+
const api = String(process.env.RESEND_API_URL || 'https://api.resend.com').replace(/\/+$/, '');
|
|
389
|
+
const response = await fetch(`${api}/emails`, {
|
|
390
|
+
method: 'POST',
|
|
391
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'User-Agent': 'small-magic-link/1.0' },
|
|
392
|
+
body: JSON.stringify({
|
|
393
|
+
from: String(process.env.MAIL_FROM || '').trim() || 'small <onboarding@resend.dev>',
|
|
394
|
+
to: [email],
|
|
395
|
+
subject: ui.subject(appName),
|
|
396
|
+
text: ui.mailText(appName, link),
|
|
397
|
+
html: `<p>${ui.mailIntro(escapeHtml(appName))}</p><p><a href="${escapeHtml(link)}">${ui.signIn}</a></p>`
|
|
398
|
+
+ `<p style="color:#666">${ui.mailNote}</p>`,
|
|
399
|
+
}),
|
|
400
|
+
});
|
|
401
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} ${(await response.text()).slice(0, 200)}`);
|
|
402
|
+
console.log(ownerText(`[magic-link] sign-in email sent: ${email}`, `[magic-link] письмо для входа отправлено: ${email}`));
|
|
403
|
+
} catch (e) {
|
|
404
|
+
// Письмо — не повод ронять приложение; ключ в лог не пишем.
|
|
405
|
+
console.log(ownerText(`[magic-link] could not send the sign-in email to ${email}: `,
|
|
406
|
+
`[magic-link] не удалось отправить письмо для входа на ${email}: `) + e.message);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Текст для владельца (лог, Telegram) — на его языке, OWNER_LANG. Посетитель видит язык своего браузера.
|
|
411
|
+
function ownerText(en, ru) {
|
|
412
|
+
return String(process.env.OWNER_LANG || '').trim().toLowerCase().startsWith('ru') ? ru : en;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// В куке лежит не сам токен, а производное от него: убрали токен из ACCESS_TOKEN — его куки больше не подходят.
|
|
416
|
+
function cookieValue(token) {
|
|
417
|
+
return crypto.createHmac('sha256', token).update('magic-link-cookie').digest('hex');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Имя владельца секрета ("" — без имени) или null, если секрет не подошёл. pairs: [[имя, секрет], ...].
|
|
421
|
+
function owner(value, pairs) {
|
|
422
|
+
if (value == null) return null;
|
|
423
|
+
for (const [name, secret] of pairs) if (safeEqual(value, secret)) return name;
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function safeEqual(a, b) {
|
|
428
|
+
const digest = (s) => crypto.createHash('sha256').update(String(s)).digest();
|
|
429
|
+
return crypto.timingSafeEqual(digest(a), digest(b));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function readCookie(header, name) {
|
|
433
|
+
for (const part of String(header || '').split(';')) {
|
|
434
|
+
const eq = part.indexOf('=');
|
|
435
|
+
if (eq > 0 && part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function escapeHtml(s) {
|
|
441
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function requestForm(action, placeholder, button) {
|
|
445
|
+
return `<form method="post" action="${action}">`
|
|
446
|
+
+ `<input name="contact" required maxlength="200" placeholder="${placeholder}">`
|
|
447
|
+
+ `<button class="btn" type="submit">${button}</button></form>`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function loginForm(action, button) {
|
|
451
|
+
return `<form method="post" action="${action}">`
|
|
452
|
+
+ '<input name="email" type="email" required maxlength="254" autocomplete="email" placeholder="name@company.com">'
|
|
453
|
+
+ `<button class="btn" type="submit">${button}</button></form>`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function confirmForm(action, token, button) {
|
|
457
|
+
return `<form method="post" action="${action}"><input type="hidden" name="t" value="${token}">`
|
|
458
|
+
+ `<button class="btn" type="submit">${button}</button></form>`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function renderPage(lang, icon, title, text, action) {
|
|
462
|
+
return `<!doctype html>
|
|
463
|
+
<html lang="${lang}">
|
|
464
|
+
<head>
|
|
465
|
+
<meta charset="utf-8">
|
|
466
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
467
|
+
<meta name="robots" content="noindex">
|
|
468
|
+
<title>${title}</title>
|
|
469
|
+
<style>
|
|
470
|
+
:root { color-scheme: light dark; --bg: #f5f5f4; --card: #fff; --text: #1c1917; --muted: #57534e;
|
|
471
|
+
--border: #e7e5e4; --accent: #2563eb; }
|
|
472
|
+
@media (prefers-color-scheme: dark) {
|
|
473
|
+
:root { --bg: #0c0a09; --card: #1c1917; --text: #f5f5f4; --muted: #a8a29e; --border: #292524; --accent: #3b82f6; }
|
|
474
|
+
}
|
|
475
|
+
* { box-sizing: border-box; }
|
|
476
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 16px;
|
|
477
|
+
background: var(--bg); color: var(--text); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
478
|
+
main { width: 100%; max-width: 400px; padding: 32px; text-align: center;
|
|
479
|
+
background: var(--card); border: 1px solid var(--border); border-radius: 12px; }
|
|
480
|
+
.icon { font-size: 32px; line-height: 1; margin-bottom: 12px; }
|
|
481
|
+
h1 { margin: 0 0 8px; font-size: 20px; }
|
|
482
|
+
p { margin: 0 0 24px; color: var(--muted); overflow-wrap: anywhere; }
|
|
483
|
+
input { width: 100%; margin-bottom: 12px; padding: 10px 12px; font: inherit; color: inherit;
|
|
484
|
+
background: transparent; border: 1px solid var(--border); border-radius: 8px; }
|
|
485
|
+
.btn { display: block; width: 100%; padding: 10px 16px; font: inherit; font-weight: 600; color: #fff;
|
|
486
|
+
background: var(--accent); border: 0; border-radius: 8px; text-decoration: none; cursor: pointer; }
|
|
487
|
+
.btn:hover { filter: brightness(1.1); }
|
|
488
|
+
.link { color: var(--accent); }
|
|
489
|
+
</style>
|
|
490
|
+
</head>
|
|
491
|
+
<body>
|
|
492
|
+
<main>
|
|
493
|
+
<div class="icon" aria-hidden="true">${icon}</div>
|
|
494
|
+
<h1>${title}</h1>
|
|
495
|
+
<p>${text}</p>
|
|
496
|
+
${action}
|
|
497
|
+
</main>
|
|
498
|
+
</body>
|
|
499
|
+
</html>
|
|
500
|
+
`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
module.exports = magicLink;
|
|
504
|
+
module.exports.magicLink = magicLink;
|
|
505
|
+
module.exports.parseTokens = parseTokens;
|
|
506
|
+
module.exports.parseEmails = parseEmails;
|
|
507
|
+
module.exports.pickLang = pickLang;
|
|
508
|
+
// Middleware вызывает magicLink.sendLoginMail — так тесты могут перехватить отправку письма.
|
|
509
|
+
module.exports.sendLoginMail = sendLoginMail;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// A small app created by `small init`. Deploy it with `small deploy`.
|
|
3
|
+
//
|
|
4
|
+
// Who gets in is decided by magic-link.js: people you invite by work email (`small access add ivan@company.com`)
|
|
5
|
+
// or with a personal invite link (`small access add bob`). Write your own code in handler().
|
|
6
|
+
|
|
7
|
+
const http = require('node:http');
|
|
8
|
+
const magicLink = require('./magic-link');
|
|
9
|
+
|
|
10
|
+
const PAGE = `<!doctype html>
|
|
11
|
+
<html lang="en">
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="utf-8">
|
|
14
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
15
|
+
<title>small app</title>
|
|
16
|
+
</head>
|
|
17
|
+
<body style="font-family: system-ui, sans-serif; text-align: center; padding: 48px 16px;">
|
|
18
|
+
<h1>🚀 It works!</h1>
|
|
19
|
+
<p>This app was created by <code>small init</code>. Edit <code>server.js</code> and run <code>small deploy</code>.</p>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
function handler(req, res) {
|
|
25
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
26
|
+
res.end(PAGE);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const guard = magicLink();
|
|
30
|
+
const host = process.env.HOST || '0.0.0.0';
|
|
31
|
+
const port = Number(process.env.PORT) || 8080;
|
|
32
|
+
http.createServer((req, res) => guard(req, res, () => handler(req, res)))
|
|
33
|
+
.listen(port, host, () => console.log(`Listening on ${host}:${port}`));
|