mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-10 17:02:20 +02:00
Compare commits
3
Commits
fcbddf3845
...
a9b208f470
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b208f470 | ||
|
|
d4cd6d60f1 | ||
|
|
ac05dff73c |
+12
-10
@@ -176,16 +176,17 @@ class AuthManager:
|
||||
)
|
||||
old_user = "admin"
|
||||
old_hash = self._config["password_hash"]
|
||||
self._config = {
|
||||
"users": {
|
||||
old_user: {
|
||||
"password_hash": old_hash,
|
||||
"created": time.time(),
|
||||
"is_admin": True,
|
||||
with self._config_lock:
|
||||
self._config = {
|
||||
"users": {
|
||||
old_user: {
|
||||
"password_hash": old_hash,
|
||||
"created": time.time(),
|
||||
"is_admin": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self._save()
|
||||
self._save()
|
||||
logger.info(f"Migrated single-user auth to multi-user (admin: {old_user})")
|
||||
|
||||
def _drop_reserved_loaded_users(self):
|
||||
@@ -204,8 +205,9 @@ class AuthManager:
|
||||
continue
|
||||
normalized[key] = data
|
||||
if removed or normalized != users:
|
||||
self._config["users"] = normalized
|
||||
self._save()
|
||||
with self._config_lock:
|
||||
self._config["users"] = normalized
|
||||
self._save()
|
||||
if removed:
|
||||
logger.warning(
|
||||
"Removed reserved username(s) from auth config: %s",
|
||||
|
||||
@@ -299,6 +299,16 @@ To expose Odysseus on a local network or Tailscale with HTTPS:
|
||||
```
|
||||
4. Install the `mkcert` CA on any other device you want to access Odysseus from (e.g., for iOS, email the `rootCA.pem` to yourself, install the profile, and trust it in Certificate Trust Settings).
|
||||
|
||||
### Common self-host traps (30-second fixes)
|
||||
A grab-bag of small gotchas that otherwise turn into long debugging sessions.
|
||||
|
||||
- **`AUTH_ENABLED=false` is ignored / you're still forced to log in (Windows).** If you edited `.env` in Notepad it may have saved a UTF-8 **BOM**, turning the first key into `AUTH_ENABLED` so it is never matched. Odysseus loads `.env` with `encoding="utf-8-sig"` to tolerate a leading BOM, but the safe fix is to re-save `.env` as **UTF-8 without BOM** (VS Code: *Save with Encoding → UTF-8*).
|
||||
- **macOS: the app isn't at `http://localhost:7000`.** macOS AirPlay Receiver usually holds port `7000`, so the macOS start script serves on **`7860`** instead — open `http://localhost:7860`. To use `7000`, free it (System Settings → General → AirDrop & Handoff → turn off *AirPlay Receiver*) and set `APP_PORT=7000`.
|
||||
- **Copy buttons do nothing over a plain-HTTP Tailscale/LAN URL.** Browsers only expose the clipboard API (`navigator.clipboard`) on **secure origins** — HTTPS, or `localhost`. Over `http://100.x.y.z:7860` it is blocked. Serve over HTTPS (see *HTTPS + LAN/Tailscale exposure* above); `localhost` is exempt, so copy still works on the host itself.
|
||||
- **Self-hosted ntfy reminders don't reach your phone.** Two things: (1) the bundled ntfy binds to loopback by default — to reach it from your phone set `NTFY_BIND` to your host/Tailscale IP and `NTFY_BASE_URL` to the same server URL in `.env`, then recreate the ntfy container (see the `NTFY_*` block in `.env.example`); (2) in the ntfy **Android** app, subscribe to the topic with **Instant delivery** enabled — non-`ntfy.sh` servers don't get instant push otherwise.
|
||||
- **Local mail (Dovecot) login fails: "Plaintext authentication disallowed on non-encrypted connections."** Your IMAP/SMTP server is refusing cleartext auth over an unencrypted link. Prefer enabling TLS on the mail server; on a trusted LAN only, you can allow cleartext (Dovecot: `disable_plaintext_auth = no`).
|
||||
- **Calendar/contacts (Radicale) won't sync.** Point Odysseus at the **full collection URL** with its trailing slash — e.g. `http://host:5232/<user>/<collection-id>/` — not just the server root. Radicale shows this address for each calendar/address book in its web UI.
|
||||
|
||||
### Optional Dependencies
|
||||
`requirements-optional.txt` contains packages that unlock extra features. It is not installed by default.
|
||||
|
||||
|
||||
+36
-8
@@ -64,6 +64,21 @@ ODYSSEUS_MAIL_ORIGIN = "odysseus-ui"
|
||||
EMAIL_READ_ATTACHMENT_VERSION = 2
|
||||
|
||||
|
||||
def _coerce_port(value, default):
|
||||
"""Coerce a user-supplied port to int.
|
||||
|
||||
Returns ``(port, error)``. A missing or blank value yields ``default``; a
|
||||
non-numeric value yields ``(None, message)`` so callers can return a clean
|
||||
error instead of letting ``int()`` raise and surface as an HTTP 500.
|
||||
"""
|
||||
if value in (None, ""):
|
||||
return default, None
|
||||
try:
|
||||
return int(value), None
|
||||
except (TypeError, ValueError):
|
||||
return None, f"Invalid port {value!r}; must be a whole number"
|
||||
|
||||
|
||||
def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[str]:
|
||||
aliases = [owner or ""]
|
||||
try:
|
||||
@@ -3329,6 +3344,12 @@ def setup_email_routes():
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return {"ok": False, "error": "name required"}
|
||||
imap_port, port_err = _coerce_port(data.get("imap_port"), 993)
|
||||
if port_err:
|
||||
return {"ok": False, "error": port_err}
|
||||
smtp_port, port_err = _coerce_port(data.get("smtp_port"), 465)
|
||||
if port_err:
|
||||
return {"ok": False, "error": port_err}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = EmailAccount(
|
||||
@@ -3337,13 +3358,13 @@ def setup_email_routes():
|
||||
is_default=bool(data.get("is_default", False)),
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
imap_host=(data.get("imap_host") or "").strip(),
|
||||
imap_port=int(data.get("imap_port") or 993),
|
||||
imap_port=imap_port,
|
||||
imap_user=(data.get("imap_user") or "").strip(),
|
||||
imap_password=_enc(data.get("imap_password") or ""),
|
||||
imap_starttls=bool(data.get("imap_starttls", True)),
|
||||
smtp_host=(data.get("smtp_host") or "").strip(),
|
||||
smtp_port=int(data.get("smtp_port") or 465),
|
||||
smtp_security=_smtp_security_mode({"smtp_security": data.get("smtp_security"), "smtp_port": data.get("smtp_port") or 465}),
|
||||
smtp_port=smtp_port,
|
||||
smtp_security=_smtp_security_mode({"smtp_security": data.get("smtp_security"), "smtp_port": smtp_port}),
|
||||
smtp_user=(data.get("smtp_user") or "").strip(),
|
||||
smtp_password=_enc(data.get("smtp_password") or ""),
|
||||
from_address=(data.get("from_address") or "").strip(),
|
||||
@@ -3387,7 +3408,10 @@ def setup_email_routes():
|
||||
setattr(row, key, (data[key] or "").strip())
|
||||
for key in ("imap_port", "smtp_port"):
|
||||
if data.get(key) not in (None, ""):
|
||||
setattr(row, key, int(data[key]))
|
||||
port, port_err = _coerce_port(data.get(key), None)
|
||||
if port_err:
|
||||
return {"ok": False, "error": port_err}
|
||||
setattr(row, key, port)
|
||||
if "smtp_security" in data:
|
||||
row.smtp_security = _smtp_security_mode({"smtp_security": data.get("smtp_security"), "smtp_port": data.get("smtp_port") or row.smtp_port})
|
||||
for key in ("imap_starttls", "enabled"):
|
||||
@@ -3491,12 +3515,14 @@ def setup_email_routes():
|
||||
smtp_result = None
|
||||
|
||||
imap_host = (body.get("imap_host") or "").strip()
|
||||
imap_port = int(body.get("imap_port") or 993)
|
||||
imap_port, imap_port_err = _coerce_port(body.get("imap_port"), 993)
|
||||
imap_user = (body.get("imap_user") or "").strip()
|
||||
imap_pass = body.get("imap_password") or ""
|
||||
imap_starttls = bool(body.get("imap_starttls"))
|
||||
|
||||
if not (imap_host and imap_user and imap_pass):
|
||||
if imap_port_err:
|
||||
imap_result = {"ok": False, "error": imap_port_err}
|
||||
elif not (imap_host and imap_user and imap_pass):
|
||||
imap_result = {"ok": False, "error": "Need IMAP host, username, and password"}
|
||||
else:
|
||||
# Connection mode resolution:
|
||||
@@ -3523,8 +3549,10 @@ def setup_email_routes():
|
||||
imap_result = {"ok": False, "error": _friendly_email_auth_error("IMAP", imap_host, e)}
|
||||
|
||||
smtp_host = (body.get("smtp_host") or "").strip()
|
||||
if smtp_host:
|
||||
smtp_port = int(body.get("smtp_port") or 465)
|
||||
smtp_port, smtp_port_err = _coerce_port(body.get("smtp_port"), 465)
|
||||
if smtp_host and smtp_port_err:
|
||||
smtp_result = {"ok": False, "error": smtp_port_err}
|
||||
elif smtp_host:
|
||||
smtp_security = _smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port})
|
||||
smtp_user = (body.get("smtp_user") or imap_user).strip()
|
||||
smtp_pass = body.get("smtp_password") or imap_pass
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""User-supplied IMAP/SMTP ports must not crash the email-account endpoints.
|
||||
|
||||
A non-numeric port (for example ``"imap"`` or ``"993x"``) previously reached an
|
||||
unguarded ``int(...)`` in create / update / test-config and raised ``ValueError``,
|
||||
which surfaces as an HTTP 500. The endpoints should reject it with their standard
|
||||
``{"ok": False, "error": ...}`` response instead.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _route_endpoint(router, path: str, method: str):
|
||||
method = method.upper()
|
||||
for route in router.routes:
|
||||
if route.path == path and method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise AssertionError(f"route not found: {method} {path}")
|
||||
|
||||
|
||||
def test_coerce_port_accepts_int_and_numeric_string():
|
||||
import routes.email_routes as email_routes
|
||||
assert email_routes._coerce_port(2525, 993) == (2525, None)
|
||||
assert email_routes._coerce_port("465", 993) == (465, None)
|
||||
|
||||
|
||||
def test_coerce_port_blank_uses_default():
|
||||
import routes.email_routes as email_routes
|
||||
assert email_routes._coerce_port(None, 993) == (993, None)
|
||||
assert email_routes._coerce_port("", 465) == (465, None)
|
||||
|
||||
|
||||
def test_coerce_port_rejects_non_numeric():
|
||||
import routes.email_routes as email_routes
|
||||
port, err = email_routes._coerce_port("imap", 993)
|
||||
assert port is None
|
||||
assert err and "port" in err.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_rejects_non_numeric_port():
|
||||
"""A bad port is rejected before any DB work, with the endpoint's error shape."""
|
||||
import routes.email_routes as email_routes
|
||||
router = email_routes.setup_email_routes()
|
||||
create = _route_endpoint(router, "/api/email/accounts", "POST")
|
||||
result = await create(
|
||||
{
|
||||
"name": "Test",
|
||||
"imap_host": "mail.example.com",
|
||||
"imap_user": "u",
|
||||
"imap_password": "p",
|
||||
"imap_port": "not-a-number",
|
||||
},
|
||||
owner="alice",
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "port" in result["error"].lower()
|
||||
Reference in New Issue
Block a user