NetDesk 1.5.0 — source release
This commit is contained in:
commit
40318aafdd
227 files changed
+80798
No files matched your search
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-shot patch: add user accounts + email auth to netdesk_server.py."""
|
||||
import re
|
||||
|
||||
p = "server/netdesk_server.py"
|
||||
s = open(p, encoding="utf-8").read()
|
||||
|
||||
ANCHOR = "TUNNELS: dict[int, web.WebSocketResponse] = {} # tid -> viewer-side socket\nTUNNEL_TID = [1]"
|
||||
assert ANCHOR in s, "tunnel anchor missing"
|
||||
|
||||
BLOCK = '''
|
||||
# ---------------------------------------------------------------- user accounts (email sign-in for viewers)
|
||||
import sqlite3
|
||||
import smtplib
|
||||
from email.message import EmailMessage as _MailMsg
|
||||
|
||||
ACCOUNTS_DB = os.path.join(_server_root, "accounts.db")
|
||||
USER_SESSIONS = {} # token -> email
|
||||
|
||||
def _acct_db():
|
||||
conn = sqlite3.connect(ACCOUNTS_DB)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" email TEXT UNIQUE NOT NULL, pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL,"
|
||||
" verified INTEGER NOT NULL DEFAULT 0, verify_token TEXT,"
|
||||
" reset_token TEXT, reset_exp INTEGER, created INTEGER NOT NULL)")
|
||||
return conn
|
||||
|
||||
def _pbkdf(password, salt):
|
||||
import hashlib as _h
|
||||
return _h.pbkdf2_hmac("sha256", password.encode(), salt, 200_000).hex()
|
||||
|
||||
def _mail_html(title, body_html, link=None, link_text=None):
|
||||
link_html = ""
|
||||
if link:
|
||||
link_html = (
|
||||
'<p style="margin:18px 0">'
|
||||
'<a href="' + link + '" style="background:#3d7eff;color:#fff;padding:11px 22px;'
|
||||
'border-radius:8px;text-decoration:none;font-weight:600">' + link_text + "</a></p>"
|
||||
'<p style="color:#8b95a9;font-size:12px">Or paste this link: ' + link + "</p>")
|
||||
logo = ('<span style="background:#3d7eff;border-radius:8px;width:34px;height:34px;'
|
||||
'display:inline-flex;align-items:center;justify-content:center">'
|
||||
'<svg width="20" height="20" viewBox="0 0 32 32">'
|
||||
'<path d="M8 20l8-12 8 12-8-4z" fill="#fff"/>'
|
||||
'<rect x="12" y="18" width="8" height="7" rx="2" fill="#fff"/></svg></span>')
|
||||
return (
|
||||
'<div style="font-family:Segoe UI,Arial,sans-serif;max-width:520px;margin:0 auto;'
|
||||
'background:#11151f;color:#e8edf6;border:1px solid #222b3d;border-radius:14px;padding:26px">'
|
||||
'<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px">' + logo +
|
||||
'<b style="font-size:19px">NetDesk</b></div>'
|
||||
'<h2 style="margin:0 0 10px;font-size:17px">' + title + "</h2>"
|
||||
'<div style="font-size:14px;line-height:1.6;color:#c7cfe0">' + body_html + "</div>"
|
||||
+ link_html +
|
||||
'<p style="color:#5d6777;font-size:11.5px;margin-top:22px">'
|
||||
"If you did not request this, ignore this email.</p></div>")
|
||||
|
||||
def send_mail(to, subject, html):
|
||||
if not ARGS.mail_from:
|
||||
raise RuntimeError("email not configured on this server")
|
||||
msg = _MailMsg()
|
||||
msg["From"] = "NetDesk <" + ARGS.mail_from + ">"
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
msg.set_content("Your mail client does not support HTML.")
|
||||
msg.add_alternative(html, subtype="html")
|
||||
host = ARGS.smtp_host or "127.0.0.1"
|
||||
with smtplib.SMTP(host, 25, timeout=20) as smtp:
|
||||
smtp.send_message(msg)
|
||||
|
||||
def user_from_cookie(req):
|
||||
if not ARGS.mail_from:
|
||||
return None
|
||||
tok = req.cookies.get("nd_user", "")
|
||||
return USER_SESSIONS.get(tok)
|
||||
|
||||
async def auth_signup(req):
|
||||
body = await req.json()
|
||||
email = str(body.get("email", "")).strip().lower()
|
||||
pw = str(body.get("password", ""))
|
||||
if "@" not in email or len(email) < 5:
|
||||
return web.json_response({"error": "enter a valid email"}, status=400)
|
||||
if len(pw) < 8:
|
||||
return web.json_response({"error": "password needs 8+ characters"}, status=400)
|
||||
salt = _secrets.token_bytes(16)
|
||||
vtok = _secrets.token_urlsafe(24)
|
||||
now = int(time.time())
|
||||
with _acct_db() as conn:
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO users(email,pw_salt,pw_hash,verified,verify_token,created) "
|
||||
"VALUES(?,?,?,?,?,?)", (email, salt.hex(), _pbkdf(pw, salt), 0, vtok, now))
|
||||
except sqlite3.IntegrityError:
|
||||
row = conn.execute("SELECT verified FROM users WHERE email=?", (email,)).fetchone()
|
||||
return web.json_response(
|
||||
{"error": "email already registered - sign in or reset password",
|
||||
"unverified": bool(row and not row[0])}, status=409)
|
||||
link = ARGS.public_url + "/api/auth/verify?token=" + vtok
|
||||
send_mail(email, "Verify your NetDesk account",
|
||||
_mail_html("Confirm your email",
|
||||
"Welcome to NetDesk. Click below to activate your account "
|
||||
"and start connecting to your computers.", link, "Verify my email"))
|
||||
log_event("auth", "signup: " + email)
|
||||
return web.json_response({"ok": True, "msg": "verification email sent"})
|
||||
|
||||
async def auth_verify(req):
|
||||
tok = req.query.get("token", "")
|
||||
with _acct_db() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE verify_token=? AND verified=0", (tok,)).fetchone()
|
||||
if not row:
|
||||
return web.Response(status=400, text="invalid or used link")
|
||||
conn.execute("UPDATE users SET verified=1, verify_token=NULL WHERE id=?", (row[0],))
|
||||
log_event("auth", "email verified")
|
||||
raise web.HTTPFound(ARGS.public_url + "/?verified=1")
|
||||
|
||||
async def auth_login(req):
|
||||
body = await req.json()
|
||||
email = str(body.get("email", "")).strip().lower()
|
||||
pw = str(body.get("password", ""))
|
||||
with _acct_db() as conn:
|
||||
row = conn.execute("SELECT pw_salt,pw_hash,verified FROM users WHERE email=?", (email,)).fetchone()
|
||||
if not row:
|
||||
return web.json_response({"error": "wrong email or password"}, status=401)
|
||||
if _pbkdf(pw, bytes.fromhex(row[0])) != row[1]:
|
||||
return web.json_response({"error": "wrong email or password"}, status=401)
|
||||
if not row[2]:
|
||||
return web.json_response({"error": "check your inbox - email not verified yet"}, status=403)
|
||||
tok = _secrets.token_urlsafe(24)
|
||||
USER_SESSIONS[tok] = email
|
||||
resp = web.json_response({"ok": True, "email": email})
|
||||
resp.set_cookie("nd_user", tok, httponly=True, samesite="Lax", max_age=30 * 86400)
|
||||
log_event("auth", "viewer signed in: " + email)
|
||||
return resp
|
||||
|
||||
async def auth_me(req):
|
||||
email = user_from_cookie(req)
|
||||
if not email:
|
||||
return web.json_response({"error": "not signed in"}, status=401)
|
||||
return web.json_response({"email": email})
|
||||
|
||||
async def auth_logout(req):
|
||||
tok = req.cookies.get("nd_user", "")
|
||||
USER_SESSIONS.pop(tok, None)
|
||||
resp = web.json_response({"ok": True})
|
||||
resp.del_cookie("nd_user")
|
||||
return resp
|
||||
|
||||
async def auth_forgot(req):
|
||||
body = await req.json()
|
||||
email = str(body.get("email", "")).strip().lower()
|
||||
with _acct_db() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE email=? AND verified=1", (email,)).fetchone()
|
||||
if row:
|
||||
tok = _secrets.token_urlsafe(24)
|
||||
conn.execute("UPDATE users SET reset_token=?, reset_exp=? WHERE id=?",
|
||||
(tok, int(time.time()) + 1800, row[0]))
|
||||
link = ARGS.public_url + "/?reset=" + tok
|
||||
send_mail(email, "Reset your NetDesk password",
|
||||
_mail_html("Password reset",
|
||||
"Click below to choose a new password. "
|
||||
"The link expires in 30 minutes.", link, "Reset password"))
|
||||
return web.json_response(
|
||||
{"ok": True, "msg": "if that account exists, a reset email was sent"})
|
||||
|
||||
async def auth_reset(req):
|
||||
body = await req.json()
|
||||
tok, pw = str(body.get("token", "")), str(body.get("password", ""))
|
||||
if len(pw) < 8:
|
||||
return web.json_response({"error": "password needs 8+ characters"}, status=400)
|
||||
with _acct_db() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE reset_token=? AND reset_exp>?",
|
||||
(tok, int(time.time()))).fetchone()
|
||||
if not row:
|
||||
return web.json_response({"error": "link expired or invalid"}, status=400)
|
||||
salt = _secrets.token_bytes(16)
|
||||
conn.execute("UPDATE users SET pw_salt=?, pw_hash=?, reset_token=NULL, reset_exp=NULL "
|
||||
"WHERE id=?", (salt.hex(), _pbkdf(pw, salt), row[0]))
|
||||
log_event("auth", "password reset")
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
'''
|
||||
|
||||
# insert block before portal section
|
||||
s = s.replace(ANCHOR, BLOCK + ANCHOR, 1)
|
||||
|
||||
# CLI args
|
||||
s = s.replace(
|
||||
' ap.add_argument("--access-path", dest="access_path",\n'
|
||||
' help="secret URL segment hosting the UI (random hash); root serves 404")',
|
||||
' ap.add_argument("--access-path", dest="access_path",\n'
|
||||
' help="secret URL segment hosting the UI (random hash); root serves 404")\n'
|
||||
' ap.add_argument("--mail-from", dest="mail_from", default="",\n'
|
||||
' help="sender address enabling email accounts, e.g. noreply@netdesk.space")\n'
|
||||
' ap.add_argument("--smtp-host", dest="smtp_host", default="127.0.0.1",\n'
|
||||
' help="SMTP relay for verification mail (default localhost postfix)")\n'
|
||||
' ap.add_argument("--public-url", dest="public_url", default="",\n'
|
||||
' help="public base URL used in email links, e.g. https://netdesk.space")')
|
||||
|
||||
# middleware-free auth routes (public)
|
||||
s = s.replace(
|
||||
' app.router.add_get("/tunnel/{hid}", tunnel_ws)',
|
||||
' app.router.add_get("/tunnel/{hid}", tunnel_ws)\n'
|
||||
' # viewer email accounts\n'
|
||||
' app.router.add_post("/api/auth/signup", auth_signup)\n'
|
||||
' app.router.add_post("/api/auth/login", auth_login)\n'
|
||||
' app.router.add_post("/api/auth/forgot", auth_forgot)\n'
|
||||
' app.router.add_post("/api/auth/reset", auth_reset)\n'
|
||||
' app.router.add_get("/api/auth/verify", auth_verify)\n'
|
||||
' app.router.add_get("/api/auth/me", auth_me)\n'
|
||||
' app.router.add_post("/api/auth/logout", auth_logout)')
|
||||
|
||||
# print line in main
|
||||
s = s.replace(
|
||||
' print(f"[netdesk] web portal enabled at {base}/portal (downloads: {FILES_DIR})")',
|
||||
' print(f"[netdesk] web portal enabled at {base}/portal (downloads: {FILES_DIR})")\n'
|
||||
' if ARGS.mail_from:\n'
|
||||
' print(f"[netdesk] viewer accounts ON - mail from {ARGS.mail_from} via {ARGS.smtp_host}:25")')
|
||||
|
||||
open(p, "w", encoding="utf-8").write(s)
|
||||
print("server accounts patch applied")
|
||||
Reference in new issue
Block a user