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.
- minimost/__init__.py +414 -0
- minimost/__main__.py +190 -0
- minimost/_pki.py +722 -0
- minimost/_version.py +14 -0
- minimost/auth.py +655 -0
- minimost/calls.py +1315 -0
- minimost/certs.py +229 -0
- minimost/chat.py +2776 -0
- minimost/clean.py +373 -0
- minimost/common.py +255 -0
- minimost/database.py +120 -0
- minimost/gunicorn_conf.py +111 -0
- minimost/presence.py +495 -0
- minimost/preview.py +767 -0
- minimost/settings.json +13 -0
- minimost/static/auth-password-field.js +73 -0
- minimost/static/auth-password-rules.js +89 -0
- minimost/static/auth.css +350 -0
- minimost/static/call_accepted.mp3 +0 -0
- minimost/static/calling.mp3 +0 -0
- minimost/static/chat-calls.js +1409 -0
- minimost/static/chat-channels.js +358 -0
- minimost/static/chat-dm.js +302 -0
- minimost/static/chat-mentions.js +416 -0
- minimost/static/chat-reactions.js +753 -0
- minimost/static/chat-search.js +1577 -0
- minimost/static/chat-settings.js +776 -0
- minimost/static/chat-sidebar.js +573 -0
- minimost/static/favicon.svg +8 -0
- minimost/static/hang_up.mp3 +0 -0
- minimost/static/left_call.mp3 +0 -0
- minimost/static/minimost-icon-16.svg +5 -0
- minimost/static/minimost-icon-32.svg +5 -0
- minimost/static/minimost-icon-60.svg +8 -0
- minimost/static/minimost-logo.svg +10 -0
- minimost/static/notification.mp3 +0 -0
- minimost/static/receiving_call.mp3 +0 -0
- minimost/static/retention.png +0 -0
- minimost/static/site.webmanifest +24 -0
- minimost/static/styles.css +3026 -0
- minimost/static/sw.js +41 -0
- minimost/static/web-app-manifest-192x192.png +0 -0
- minimost/static/web-app-manifest-512x512.png +0 -0
- minimost/stun.py +144 -0
- minimost/templates/_password_fields.html +43 -0
- minimost/templates/about.html +119 -0
- minimost/templates/chat.html +2152 -0
- minimost/templates/forgot_password.html +33 -0
- minimost/templates/login.html +48 -0
- minimost/templates/reset_password.html +71 -0
- minimost/templates/signup.html +48 -0
- minimost-0.0.1.dist-info/METADATA +336 -0
- minimost-0.0.1.dist-info/RECORD +57 -0
- minimost-0.0.1.dist-info/WHEEL +5 -0
- minimost-0.0.1.dist-info/entry_points.txt +2 -0
- minimost-0.0.1.dist-info/licenses/LICENSE +21 -0
- 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()
|