minimost 0.0.1__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 (57) hide show
  1. minimost/__init__.py +414 -0
  2. minimost/__main__.py +190 -0
  3. minimost/_pki.py +722 -0
  4. minimost/_version.py +14 -0
  5. minimost/auth.py +655 -0
  6. minimost/calls.py +1315 -0
  7. minimost/certs.py +229 -0
  8. minimost/chat.py +2776 -0
  9. minimost/clean.py +373 -0
  10. minimost/common.py +255 -0
  11. minimost/database.py +120 -0
  12. minimost/gunicorn_conf.py +111 -0
  13. minimost/presence.py +495 -0
  14. minimost/preview.py +767 -0
  15. minimost/settings.json +13 -0
  16. minimost/static/auth-password-field.js +73 -0
  17. minimost/static/auth-password-rules.js +89 -0
  18. minimost/static/auth.css +350 -0
  19. minimost/static/call_accepted.mp3 +0 -0
  20. minimost/static/calling.mp3 +0 -0
  21. minimost/static/chat-calls.js +1409 -0
  22. minimost/static/chat-channels.js +358 -0
  23. minimost/static/chat-dm.js +302 -0
  24. minimost/static/chat-mentions.js +416 -0
  25. minimost/static/chat-reactions.js +753 -0
  26. minimost/static/chat-search.js +1577 -0
  27. minimost/static/chat-settings.js +776 -0
  28. minimost/static/chat-sidebar.js +573 -0
  29. minimost/static/favicon.svg +8 -0
  30. minimost/static/hang_up.mp3 +0 -0
  31. minimost/static/left_call.mp3 +0 -0
  32. minimost/static/minimost-icon-16.svg +5 -0
  33. minimost/static/minimost-icon-32.svg +5 -0
  34. minimost/static/minimost-icon-60.svg +8 -0
  35. minimost/static/minimost-logo.svg +10 -0
  36. minimost/static/notification.mp3 +0 -0
  37. minimost/static/receiving_call.mp3 +0 -0
  38. minimost/static/retention.png +0 -0
  39. minimost/static/site.webmanifest +24 -0
  40. minimost/static/styles.css +3026 -0
  41. minimost/static/sw.js +41 -0
  42. minimost/static/web-app-manifest-192x192.png +0 -0
  43. minimost/static/web-app-manifest-512x512.png +0 -0
  44. minimost/stun.py +144 -0
  45. minimost/templates/_password_fields.html +43 -0
  46. minimost/templates/about.html +119 -0
  47. minimost/templates/chat.html +2152 -0
  48. minimost/templates/forgot_password.html +33 -0
  49. minimost/templates/login.html +48 -0
  50. minimost/templates/reset_password.html +71 -0
  51. minimost/templates/signup.html +48 -0
  52. minimost-0.0.1.dist-info/METADATA +336 -0
  53. minimost-0.0.1.dist-info/RECORD +57 -0
  54. minimost-0.0.1.dist-info/WHEEL +5 -0
  55. minimost-0.0.1.dist-info/entry_points.txt +2 -0
  56. minimost-0.0.1.dist-info/licenses/LICENSE +21 -0
  57. minimost-0.0.1.dist-info/top_level.txt +1 -0
minimost/__init__.py ADDED
@@ -0,0 +1,414 @@
1
+ """
2
+ minimost
3
+ ========
4
+
5
+ Flask application factory for the MiniMost chat platform.
6
+
7
+ This module is the primary entry point for the MiniMost application. It exposes
8
+ :func:`create_app`, which constructs a fully-configured :class:`flask.Flask`
9
+ instance ready to serve HTTP requests.
10
+
11
+ Typical usage in development::
12
+
13
+ from minimost import create_app
14
+
15
+ app = create_app()
16
+ app.run(host="127.0.0.1", port=5000)
17
+
18
+ Typical usage with a WSGI server such as Gunicorn::
19
+
20
+ gunicorn "minimost:create_app()" --config gunicorn.conf.py
21
+
22
+ Module-level attributes
23
+ -----------------------
24
+ _APP_VERSION : str
25
+ The package version string, resolved once at import time by
26
+ :func:`_read_version`. Available in every Jinja2 template as
27
+ ``{{ app_version }}``.
28
+ """
29
+
30
+ import os
31
+ import secrets
32
+ import threading
33
+ import time
34
+ from contextlib import suppress
35
+ from pathlib import Path
36
+
37
+ from flask import Flask, abort, request, send_file, session
38
+
39
+ from . import calls as calls_mod
40
+ from . import chat as chat_mod
41
+ from . import common, database, presence
42
+ from .auth import auth_bp
43
+ from .calls import calls_bp
44
+ from .chat import chat_bp
45
+ from .presence import presence_bp
46
+
47
+ _HERE = Path(__file__).resolve().parent
48
+ _PROJECT_ROOT = _HERE.parent.parent
49
+
50
+
51
+ def _read_version() -> str:
52
+ """Return the package version string.
53
+
54
+ The version lives in :mod:`minimost._version`, which ships inside the
55
+ package and is therefore importable from an installed wheel and on every
56
+ supported Python version (unlike ``importlib.metadata``, which is 3.8+).
57
+ The same module is the build-time source of truth via the dynamic-version
58
+ config in ``pyproject.toml``.
59
+
60
+ If the module cannot be imported for any reason, the string ``"unknown"``
61
+ is returned so the application always has a displayable value.
62
+
63
+ :returns: The version string, for example ``"0.1.0"``, or ``"unknown"``
64
+ if the version cannot be determined.
65
+ :rtype: str
66
+ """
67
+ with suppress(Exception):
68
+ from ._version import __version__
69
+
70
+ return __version__
71
+ return "unknown"
72
+
73
+
74
+ _APP_VERSION = _read_version()
75
+
76
+ # settings.json is bundled inside the package (src/minimost/) so it ships in
77
+ # the wheel; _HERE is the package directory.
78
+ _SETTINGS_FILE = _HERE / "settings.json"
79
+
80
+
81
+ def _max_upload_size_mb() -> int:
82
+ """Return the configured max upload size in MB (default 25)."""
83
+ import json
84
+
85
+ with suppress(Exception):
86
+ data = json.loads(_SETTINGS_FILE.read_text())
87
+ value = data.get("max_upload_size_mb")
88
+ if isinstance(value, (int, float)) and value > 0:
89
+ return int(value)
90
+ return 25
91
+
92
+
93
+ def _max_avatar_size_mb() -> int:
94
+ """Return the configured max avatar size in MB (default 5)."""
95
+ import json
96
+
97
+ with suppress(Exception):
98
+ data = json.loads(_SETTINGS_FILE.read_text())
99
+ value = data.get("max_avatar_size_mb")
100
+ if isinstance(value, (int, float)) and value > 0:
101
+ return int(value)
102
+ return 5
103
+
104
+
105
+ def _stun_port() -> int:
106
+ """Return the configured STUN UDP port (default 3478).
107
+
108
+ The bundled STUN server lets LAN WebRTC peers gather a real-IP
109
+ server-reflexive candidate, avoiding the mDNS ``.local`` host-candidate
110
+ resolution that otherwise breaks calls on LANs without avahi/Bonjour.
111
+ """
112
+ import json
113
+
114
+ from .stun import DEFAULT_STUN_PORT
115
+
116
+ with suppress(Exception):
117
+ data = json.loads(_SETTINGS_FILE.read_text())
118
+ value = data.get("stun_port")
119
+ if isinstance(value, int) and 0 < value < 65536:
120
+ return value
121
+ return DEFAULT_STUN_PORT
122
+
123
+
124
+ def _provision_tls(app) -> None:
125
+ """Generate the self-signed TLS cert/key once, for any WSGI server.
126
+
127
+ Historically only the development server and the bundled Gunicorn config
128
+ generated certificates, so running MiniMost under another WSGI server
129
+ (waitress, uWSGI, mod_wsgi, …) silently meant no HTTPS — and therefore no
130
+ voice/video calling. Doing it here means *any* server that loads
131
+ ``minimost:create_app()`` gets certificates provisioned, with no
132
+ server-specific glue.
133
+
134
+ Generation is idempotent (see :func:`minimost.certs.ensure_certs`) and the
135
+ resolved paths are stored in ``app.config['TLS_CERT_FILE']`` and
136
+ ``['TLS_KEY_FILE']`` so a launcher can point its TLS listener at them. Note
137
+ that generating the files does **not** terminate TLS — the WSGI server still
138
+ has to be configured to serve HTTPS using these paths.
139
+
140
+ Set ``MINIMOST_SKIP_TLS=1`` to skip generation entirely, e.g. when TLS is
141
+ terminated upstream by a reverse proxy, or under the test suite.
142
+
143
+ :param app: The Flask application whose config receives the cert paths.
144
+ """
145
+ if os.environ.get("MINIMOST_SKIP_TLS"):
146
+ return
147
+ from .certs import ensure_certs
148
+
149
+ # Use the process working directory (matching the Gunicorn config and the
150
+ # documented data-directory model) so it resolves correctly under an
151
+ # installed wheel, where the package dir is typically read-only.
152
+ cert, key = ensure_certs(Path.cwd())
153
+ if cert and key:
154
+ app.config["TLS_CERT_FILE"] = str(cert)
155
+ app.config["TLS_KEY_FILE"] = str(key)
156
+
157
+
158
+ def create_app():
159
+ """Create and configure the MiniMost Flask application.
160
+
161
+ This is the canonical *application factory* used by every execution path —
162
+ the CLI entry point (:mod:`minimost.__main__`), the Gunicorn WSGI
163
+ configuration, and any test suite that imports the package.
164
+
165
+ The factory performs the following steps in order:
166
+
167
+ 1. **Instantiate** a :class:`flask.Flask` application object.
168
+ 2. **Provision the secret key** — read from ``secret.key`` in the project
169
+ root, generating a fresh 64-character hex token if the file does not
170
+ exist. The secret key is required for Flask's signed session cookies.
171
+ 3. **Set upload limit** to 16 MiB via ``MAX_CONTENT_LENGTH``. Requests
172
+ that exceed this size are rejected by Flask before the route handler
173
+ runs.
174
+ 4. **Inject the version** into every Jinja2 template context via a context
175
+ processor, making ``{{ app_version }}`` available in all templates.
176
+ 5. **Register blueprints** — :data:`auth_bp <minimost.auth.auth_bp>`,
177
+ :data:`chat_bp <minimost.chat.chat_bp>`, and
178
+ :data:`presence_bp <minimost.presence.presence_bp>`.
179
+
180
+ The ``auth.db`` and ``presence.db`` databases are also initialised as a
181
+ side effect of importing :mod:`minimost.database` and
182
+ :mod:`minimost.presence` at module load time.
183
+
184
+ :returns: A configured :class:`flask.Flask` application instance.
185
+ :rtype: flask.Flask
186
+
187
+ Example::
188
+
189
+ app = create_app()
190
+ with app.test_client() as client:
191
+ response = client.get("/login")
192
+ assert response.status_code == 200
193
+ """
194
+ app = Flask(__name__)
195
+
196
+ key_file = _PROJECT_ROOT / "secret.key"
197
+ if not key_file.exists():
198
+ key_file.write_text(secrets.token_hex(32))
199
+ app.secret_key = key_file.read_text().strip()
200
+
201
+ _upload_mb = _max_upload_size_mb()
202
+ _avatar_mb = _max_avatar_size_mb()
203
+ _stun = _stun_port()
204
+ app.config["MAX_CONTENT_LENGTH"] = _upload_mb * 1024 * 1024
205
+
206
+ def _csrf_token() -> str:
207
+ """Return a per-session CSRF token, generating one if absent."""
208
+ if "_csrf_token" not in session:
209
+ session["_csrf_token"] = secrets.token_hex(32)
210
+ return session["_csrf_token"] # type: ignore[return-value]
211
+
212
+ app.jinja_env.globals["csrf_token"] = _csrf_token
213
+
214
+ @app.before_request
215
+ def _enforce_csrf():
216
+ # Only validate on state-changing methods and only when CSRF is enabled.
217
+ if not app.config.get("CSRF_ENABLED", True):
218
+ return
219
+ if request.method in ("GET", "HEAD", "OPTIONS", "TRACE"):
220
+ return
221
+ # Chat and presence routes are API endpoints protected by session auth;
222
+ # CSRF validation applies only to the HTML form routes in the auth blueprint.
223
+ if request.blueprint != "auth":
224
+ return
225
+ expected = session.get("_csrf_token", "")
226
+ submitted = request.form.get("csrf_token", "")
227
+ if not expected or not secrets.compare_digest(expected, submitted):
228
+ abort(403)
229
+
230
+ @app.context_processor
231
+ def inject_globals():
232
+ """Inject global template variables."""
233
+ return {
234
+ "app_version": _APP_VERSION,
235
+ "max_upload_mb": _upload_mb,
236
+ "max_avatar_mb": _avatar_mb,
237
+ "stun_port": _stun,
238
+ }
239
+
240
+ @app.route("/sw.js", methods=["GET"])
241
+ def service_worker():
242
+ """Serve the PWA service worker from the root scope.
243
+
244
+ The ``Service-Worker-Allowed: /`` header lets a script served from
245
+ ``/sw.js`` control the entire origin, so the installed PWA hides the
246
+ browser URL bar across all routes.
247
+ """
248
+ return (
249
+ app.send_static_file("sw.js"),
250
+ 200,
251
+ {
252
+ "Content-Type": "application/javascript",
253
+ "Service-Worker-Allowed": "/",
254
+ },
255
+ )
256
+
257
+ @app.route("/ca.pem", methods=["GET"])
258
+ def download_ca_cert():
259
+ """Serve the local CA certificate so clients can trust this server.
260
+
261
+ Importing this public certificate into the browser/OS trust store makes
262
+ the self-signed TLS leaf MiniMost serves trusted, which clears the
263
+ "Not secure" warning and lets the installed PWA hide the URL bar. Only
264
+ the public CA cert is exposed; the signing key (``ca-key.pem``) never
265
+ leaves the server. ``ca.pem`` lives in the working directory alongside
266
+ ``cert.pem``/``key.pem`` (see ``gunicorn.conf.py``).
267
+ """
268
+ ca_path = Path.cwd() / "ca.pem"
269
+ if not ca_path.is_file():
270
+ abort(404)
271
+ return send_file(
272
+ ca_path,
273
+ mimetype="application/x-pem-file",
274
+ as_attachment=True,
275
+ download_name="minimost-ca.pem",
276
+ )
277
+
278
+ app.register_blueprint(auth_bp)
279
+ app.register_blueprint(calls_bp)
280
+ app.register_blueprint(chat_bp)
281
+ app.register_blueprint(presence_bp)
282
+
283
+ presence.reset_all_offline()
284
+ calls_mod.reset_all_calls_ended()
285
+ calls_mod.reset_all_screenshares_ended()
286
+ _migrate_search_indexes()
287
+ _provision_tls(app)
288
+
289
+ from .stun import start_stun_server
290
+
291
+ start_stun_server(_stun)
292
+
293
+ _start_cleanup_scheduler()
294
+
295
+ return app
296
+
297
+
298
+ def _migrate_search_indexes() -> None:
299
+ """Ensure the shared message database and its search index exist at boot.
300
+
301
+ :func:`~minimost.common.init_messages_db` is idempotent and builds (then
302
+ rebuilds, once) the FTS5 trigram index if it is missing, so calling it at
303
+ startup transparently upgrades a database that predates the index. Runs once
304
+ per worker at boot.
305
+ """
306
+ with suppress(Exception):
307
+ common.init_messages_db()
308
+
309
+
310
+ def _start_cleanup_scheduler(
311
+ interval_hours: int = 24,
312
+ days: int = 30,
313
+ message_days: int = 770,
314
+ initial_delay_seconds: int = 5,
315
+ ) -> None:
316
+ """Start a daemon thread that periodically purges old upload files.
317
+
318
+ Runs :func:`minimost.clean.delete_files_older_than` once shortly after
319
+ startup and then every *interval_hours* hours. The thread is a daemon so
320
+ it exits automatically when the server process shuts down — no teardown
321
+ required.
322
+
323
+ The retention period is read from the ``"image_retention_days"`` key in
324
+ ``settings.json`` at each run, so changes to the file take effect on the
325
+ next scheduled cleanup without restarting the server. If the key is
326
+ absent or the file cannot be read, *days* is used as the fallback.
327
+
328
+ Two optional size caps are also honoured each run: ``"max_upload_dir_size_mb"``
329
+ bounds the total size of ``uploads/`` (oldest files deleted first), and
330
+ ``"max_message_db_size_mb"`` bounds the shared ``messages.db`` (oldest
331
+ messages deleted first). Either is disabled when its key is absent or
332
+ non-positive.
333
+
334
+ Multiple Gunicorn workers each start their own thread; concurrent runs are
335
+ safe because :func:`~minimost.clean.delete_files_older_than` tolerates
336
+ ``FileNotFoundError`` on files already removed by another worker.
337
+
338
+ :param interval_hours: Hours between cleanup runs. Defaults to ``24``.
339
+ :param days: Fallback retention period in days if ``settings.json`` does
340
+ not specify ``"image_retention_days"``. Defaults to ``30``.
341
+ :param message_days: Fallback retention period in days for messages if
342
+ ``settings.json`` does not specify ``"message_retention_days"``.
343
+ :param initial_delay_seconds: Seconds to wait after startup before the first
344
+ cleanup run, giving the server time to finish booting. Defaults to
345
+ ``5``.
346
+ """
347
+ # Resolve the data directories from the live module attributes (rather than
348
+ # ``_PROJECT_ROOT``) so the worker honours any monkeypatched paths — this is
349
+ # what keeps the test suite's cleanup runs confined to their temp dirs
350
+ # instead of touching the real ``users/`` and ``uploads/`` directories.
351
+ upload_dir = chat_mod.UPLOAD_DIR
352
+ users_dir = common.DB_DIR
353
+ settings_file = _HERE / "settings.json"
354
+
355
+ def _read_retention() -> tuple:
356
+ with suppress(Exception):
357
+ import json
358
+
359
+ data = json.loads(settings_file.read_text())
360
+ img = data.get("image_retention_days")
361
+ fil = data.get("file_retention_days")
362
+ msg = data.get("message_retention_days")
363
+ upload_mb = data.get("max_upload_dir_size_mb")
364
+ db_mb = data.get("max_message_db_size_mb")
365
+ img = img if isinstance(img, int) and img > 0 else days
366
+ fil = fil if isinstance(fil, int) and fil > 0 else days
367
+ msg = msg if isinstance(msg, int) and msg > 0 else message_days
368
+
369
+ # A size cap of 0 / absent / invalid disables that cap (None).
370
+ def _cap(value):
371
+ return value if isinstance(value, (int, float)) and value > 0 else None
372
+
373
+ return img, fil, msg, _cap(upload_mb), _cap(db_mb)
374
+ return days, days, message_days, None, None
375
+
376
+ def _loop() -> None:
377
+ time.sleep(initial_delay_seconds) # let the server finish starting
378
+ while True:
379
+ try:
380
+ from .clean import (
381
+ delete_files_older_than,
382
+ delete_files_over_size,
383
+ delete_messages_older_than,
384
+ delete_messages_over_size,
385
+ )
386
+
387
+ (
388
+ image_days,
389
+ file_days,
390
+ msg_days,
391
+ max_upload_mb,
392
+ max_db_mb,
393
+ ) = _read_retention()
394
+ # Age-based cleanup first, then trim by size whatever remains.
395
+ delete_files_older_than(
396
+ str(upload_dir),
397
+ image_days=image_days,
398
+ file_days=file_days,
399
+ )
400
+ if max_upload_mb:
401
+ delete_files_over_size(str(upload_dir), max_size_mb=max_upload_mb)
402
+ delete_messages_older_than(str(users_dir), days=msg_days)
403
+ if max_db_mb:
404
+ delete_messages_over_size(
405
+ str(common.shared_db_path()), max_size_mb=max_db_mb
406
+ )
407
+ except (
408
+ Exception
409
+ ): # nosec B110 — cleanup failure must not crash the daemon thread
410
+ pass
411
+ time.sleep(interval_hours * 3600)
412
+
413
+ thread = threading.Thread(target=_loop, daemon=True, name="minimost-cleanup")
414
+ thread.start()
minimost/__main__.py ADDED
@@ -0,0 +1,190 @@
1
+ """
2
+ minimost.__main__
3
+ =================
4
+
5
+ Command-line entry point for the MiniMost development server.
6
+
7
+ This module is invoked either through the ``minimost`` console script
8
+ installed by ``pip``, or directly with ``python -m minimost``. It parses
9
+ command-line arguments and starts the Flask built-in WSGI server.
10
+
11
+ .. note::
12
+
13
+ The built-in server is intended for **development and small private
14
+ networks** only. For production use, run MiniMost behind Gunicorn or
15
+ another WSGI server — see :doc:`/deployment`.
16
+
17
+ Usage::
18
+
19
+ # Default: binds to 127.0.0.1:5000
20
+ minimost
21
+
22
+ # Accessible from the local network on port 8080
23
+ minimost --host 0.0.0.0 --port 8080
24
+
25
+ # Equivalent without installation
26
+ python -m minimost --host 0.0.0.0 --port 8080
27
+
28
+ # Generate a password reset URL for a user (admin command)
29
+ minimost reset-password <username> [--expires MINUTES] [--base-url URL]
30
+ """
31
+
32
+ import argparse
33
+ import secrets
34
+ import sqlite3
35
+ import sys
36
+ import time
37
+
38
+ from minimost import create_app
39
+
40
+
41
+ def main():
42
+ """Parse arguments and dispatch to the appropriate command.
43
+
44
+ With no subcommand (or unrecognised first argument), starts the MiniMost
45
+ development server. Pass ``reset-password`` as the first argument to
46
+ generate a password reset URL instead.
47
+
48
+ Command-line arguments (server mode):
49
+
50
+ ``--host`` : str, optional
51
+ The IP address or hostname to bind to. Defaults to ``127.0.0.1``
52
+ (loopback only). Use ``0.0.0.0`` to accept connections on all
53
+ network interfaces.
54
+
55
+ ``--port`` : int, optional
56
+ The TCP port to listen on. Defaults to ``5000``.
57
+
58
+ :raises SystemExit: If unrecognised arguments are passed.
59
+ """
60
+ if len(sys.argv) > 1 and sys.argv[1] == "reset-password":
61
+ _cmd_reset_password(sys.argv[2:])
62
+ return
63
+
64
+ parser = argparse.ArgumentParser(
65
+ description="Run the MiniMost server",
66
+ epilog=(
67
+ "Admin commands (run without starting the server):\n"
68
+ " minimost reset-password <username> [--expires MINUTES] [--base-url URL]\n"
69
+ " Generate a one-time password reset URL for a user\n"
70
+ "\n"
71
+ "Run 'minimost reset-password --help' for details."
72
+ ),
73
+ formatter_class=argparse.RawDescriptionHelpFormatter,
74
+ )
75
+ parser.add_argument(
76
+ "--host", default="127.0.0.1", help="Address to listen on (default: 127.0.0.1)"
77
+ )
78
+ parser.add_argument(
79
+ "--port", type=int, default=5000, help="Port to listen on (default: 5000)"
80
+ )
81
+ args = parser.parse_args()
82
+
83
+ # create_app() provisions the TLS certificate (shared by every WSGI server)
84
+ # and records the resolved paths in app.config; the dev server just consumes
85
+ # them. Set MINIMOST_SKIP_TLS=1 to serve plain HTTP.
86
+ app = create_app()
87
+ cert = app.config.get("TLS_CERT_FILE")
88
+ key = app.config.get("TLS_KEY_FILE")
89
+ ssl_context = (cert, key) if cert and key else None
90
+ app.run(host=args.host, port=args.port, debug=False, ssl_context=ssl_context)
91
+
92
+
93
+ def _send_reset_dm(username: str, expires_minutes: int) -> None:
94
+ """Insert a system notification DM into the shared message database."""
95
+ from minimost.common import shared_db_path, init_messages_db
96
+
97
+ init_messages_db()
98
+ channel = "dm:" + ":".join(sorted(["system", username]))
99
+ minutes_word = "minute" if expires_minutes == 1 else "minutes"
100
+ message = (
101
+ f"A password reset has been requested for your account. "
102
+ f"The reset link will expire in {expires_minutes} {minutes_word}. "
103
+ f"If you did not request this, please contact your administrators."
104
+ )
105
+ db = sqlite3.connect(str(shared_db_path()))
106
+ db.execute("PRAGMA journal_mode=WAL")
107
+ db.execute(
108
+ "INSERT INTO messages (channel, sender, content, content_type, ts)"
109
+ " VALUES (?, ?, ?, ?, ?)",
110
+ (channel, "system", message, "system", time.time()),
111
+ )
112
+ db.commit()
113
+ db.close()
114
+
115
+
116
+ def _cmd_reset_password(argv):
117
+ """Generate a one-time password reset URL for a registered user.
118
+
119
+ Stores a cryptographically secure token in ``auth.db``, sends the user a
120
+ system DM notifying them that a reset was requested, and prints the reset
121
+ URL to stdout.
122
+
123
+ :param argv: Argument list (everything after ``reset-password``).
124
+ :type argv: list of str
125
+ """
126
+ parser = argparse.ArgumentParser(
127
+ prog="minimost reset-password",
128
+ description="Generate a password reset URL for a registered user",
129
+ )
130
+ parser.add_argument("username", help="Username to generate a reset link for")
131
+ parser.add_argument(
132
+ "--expires",
133
+ type=int,
134
+ default=60,
135
+ metavar="MINUTES",
136
+ help="Minutes until the link expires (default: 60)",
137
+ )
138
+ parser.add_argument(
139
+ "--base-url",
140
+ default="http://127.0.0.1:5000",
141
+ help="Base URL of the server (default: http://127.0.0.1:5000)",
142
+ )
143
+ args = parser.parse_args(argv)
144
+
145
+ # Import here to avoid pulling Flask into the top-level import just for the
146
+ # reset-password path, and to ensure auth.db schema is initialised.
147
+ from minimost.database import init_auth_db
148
+ from minimost.auth import AUTH_DB
149
+
150
+ _WAL = "PRAGMA journal_mode=WAL"
151
+ init_auth_db()
152
+
153
+ db = sqlite3.connect(AUTH_DB)
154
+ db.execute(_WAL)
155
+ # Match case-insensitively and use the stored spelling for the token so the
156
+ # generated link targets the account regardless of the case typed.
157
+ row = db.execute(
158
+ "SELECT username FROM users WHERE username = ? COLLATE NOCASE",
159
+ (args.username,),
160
+ ).fetchone()
161
+ if not row:
162
+ print(f"Error: user '{args.username}' does not exist", file=sys.stderr)
163
+ db.close()
164
+ sys.exit(1)
165
+ username = row[0]
166
+
167
+ token = secrets.token_urlsafe(32)
168
+ expires_ts = time.time() + args.expires * 60
169
+
170
+ db.execute(
171
+ "INSERT INTO password_reset_tokens (token, username, expires_ts, used)"
172
+ " VALUES (?, ?, ?, 0)",
173
+ (token, username, expires_ts),
174
+ )
175
+ db.commit()
176
+ db.close()
177
+
178
+ _send_reset_dm(username, args.expires)
179
+
180
+ url = f"{args.base_url.rstrip('/')}/reset-password/{token}"
181
+ minutes_word = "minute" if args.expires == 1 else "minutes"
182
+ print(
183
+ f"Password reset URL for '{username}'"
184
+ f" (expires in {args.expires} {minutes_word}):"
185
+ )
186
+ print(url)
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()