fix(merge): resolve conflicts with main v0.92.0 keeping path-param regression tests

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-08 11:19:22 +00:00
parent c31b72810e
commit 2d754d52ef
22 changed files with 2256 additions and 122 deletions
+149 -2
View File
@@ -7,7 +7,7 @@ user accounts directly, without requiring email verification.
"""
import logging
from datetime import datetime
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@@ -15,9 +15,10 @@ from pydantic import BaseModel, Field
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import FileRecord, LocalUser, UserProfile
from app.utils.local_auth import hash_password
from app.utils.local_auth import generate_token, hash_password, send_password_reset_email
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/users", tags=["admin-users"])
@@ -123,6 +124,21 @@ class LocalUserCreate(BaseModel):
is_admin: bool = Field(default=False, description="Grant admin privileges")
class LocalUserUpdate(BaseModel):
"""Body for admin-updating a local (email/password) user account."""
email: str | None = Field(default=None, max_length=255, description="New email address")
display_name: str | None = Field(default=None, max_length=255, description="New display name")
is_admin: bool | None = Field(default=None, description="Grant or revoke admin privileges")
is_active: bool | None = Field(default=None, description="Activate or deactivate the account")
class LocalUserSetPassword(BaseModel):
"""Body for admin setting a temporary password for a local user."""
password: str = Field(..., min_length=8, max_length=128, description="New temporary password")
class LocalUserResponse(BaseModel):
"""Summary of a local user account."""
@@ -355,6 +371,137 @@ def delete_local_user(local_user_id: int, db: DbSession, _admin: AdminUser) -> N
logger.info("Admin deleted local user account: %s", user.email)
@router.patch("/local/{local_user_id}", summary="Update a local user account")
def update_local_user(local_user_id: int, body: LocalUserUpdate, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Update the email address, display name, admin flag, or active status of a local user account.
Only fields explicitly provided (non-None) are modified. If the email is changed
the associated UserProfile row is also updated to keep ``user_id`` in sync.
Raises:
404: Local user not found.
409: The new email is already taken by another account.
"""
user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.")
old_email = user.email
if body.email is not None and body.email != user.email:
if db.query(LocalUser).filter(LocalUser.email == body.email, LocalUser.id != local_user_id).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered.")
user.email = body.email
if body.display_name is not None:
# Normalise empty string to None so that clearing the field removes the display name
user.display_name = body.display_name or None
if body.is_admin is not None:
user.is_admin = body.is_admin
if body.is_active is not None:
user.is_active = body.is_active
try:
db.flush()
# Keep UserProfile.user_id in sync when email changes
if body.email is not None and body.email != old_email:
profile = db.query(UserProfile).filter(UserProfile.user_id == old_email).first()
if profile:
profile.user_id = body.email
db.commit()
db.refresh(user)
except Exception:
db.rollback()
raise
logger.info("Admin updated local user %s (id=%d)", user.email, user.id)
return {
"id": user.id,
"email": user.email,
"username": user.username,
"display_name": user.display_name,
"is_active": user.is_active,
"is_admin": user.is_admin,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
@router.post(
"/local/{local_user_id}/send-password-reset",
status_code=status.HTTP_200_OK,
summary="Send a password reset email to a local user",
)
def admin_send_password_reset(local_user_id: int, request: Request, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Generate a password reset token and email the reset link to the local user.
This is a last-resort tool for admins to help users who are locked out.
Returns ``{"sent": true}`` on success and ``{"sent": false, "reason": "..."}`` when
SMTP is not configured or sending fails.
Raises:
404: Local user not found.
"""
user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.")
if not settings.email_host:
logger.warning("Admin requested password reset for %s but SMTP is not configured", user.email)
return {"sent": False, "reason": "SMTP is not configured on this server."}
token = generate_token()
user.password_reset_token = token
user.password_reset_sent_at = datetime.now(tz=timezone.utc)
db.commit()
base_url = str(request.base_url).rstrip("/")
try:
send_password_reset_email(user.email, user.username, token, base_url)
except Exception as exc:
logger.warning("Admin-triggered password reset email failed for %s: %s", user.email, exc)
return {"sent": False, "reason": str(exc)}
logger.info("[SECURITY] ADMIN_PASSWORD_RESET_EMAIL user=%s admin=%s", user.email, _admin.get("email", "unknown"))
return {"sent": True, "email": user.email}
@router.post(
"/local/{local_user_id}/set-password",
status_code=status.HTTP_200_OK,
summary="Set a temporary password for a local user account",
)
def admin_set_password(
local_user_id: int, body: LocalUserSetPassword, db: DbSession, _admin: AdminUser
) -> dict[str, Any]:
"""Directly set a new password for a local user without requiring an email token.
Use this as a last resort when email delivery is unavailable. The user
should be advised to change their password after logging in.
Raises:
404: Local user not found.
"""
user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.")
user.hashed_password = hash_password(body.password)
# Clear any outstanding reset tokens
user.password_reset_token = None
user.password_reset_sent_at = None
try:
db.commit()
except Exception:
db.rollback()
raise
logger.info("[SECURITY] ADMIN_SET_PASSWORD user=%s admin=%s", user.email, _admin.get("email", "unknown"))
return {"updated": True, "email": user.email}
@router.get("/{user_id:path}", summary="Get details for a single user")
def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Return profile and document statistics for a specific user."""
+84 -68
View File
@@ -117,88 +117,104 @@ async def restore_backup(
"""Restore the database from an uploaded gzip-compressed SQL dump.
**Warning**: This overwrites the current database contents.
Only SQLite databases are supported.
The uploaded file must be a ``.db.gz`` file produced by the DocuElevate
backup task (a gzip-compressed SQLite ``.dump()`` SQL script).
Supported formats (must match the currently configured database backend):
- ``*.db.gz`` gzip-compressed SQLite ``.dump()`` SQL script (SQLite backend)
- ``*.pgsql.gz`` gzip-compressed ``pg_dump --format=plain`` output (PostgreSQL backend)
- ``*.mysql.gz`` gzip-compressed ``mysqldump`` output (MySQL / MariaDB backend)
"""
from app.tasks.backup_tasks import _db_path
db_path = _db_path()
if db_path is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Restore is only supported for SQLite databases.",
)
if not file.filename or not file.filename.endswith(".db.gz"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Uploaded file must be a .db.gz backup archive.",
)
import gzip
import sqlite3
import tempfile
from pathlib import Path
# Write the upload to a temp file first so we can validate it
with tempfile.NamedTemporaryFile(suffix=".db.gz", delete=False) as tmp:
from sqlalchemy.engine.url import make_url
from app.config import settings as app_settings
from app.tasks.backup_tasks import (
_archive_ext_for_backend,
_db_path,
_restore_mysql,
_restore_postgresql,
_restore_sqlite,
)
url = make_url(app_settings.database_url)
backend = url.get_backend_name()
expected_ext = _archive_ext_for_backend(backend)
if not file.filename or not file.filename.endswith(expected_ext):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Uploaded file must be a '{expected_ext}' backup archive for the current database backend ({backend})."
),
)
# Write upload to a temp file
with tempfile.NamedTemporaryFile(suffix=expected_ext, delete=False) as tmp:
tmp_path = Path(tmp.name)
content = await file.read()
tmp.write(content)
try:
# Decompress and read SQL statements
with gzip.open(str(tmp_path), "rt", encoding="utf-8") as gz:
sql_script = gz.read()
except Exception as exc:
tmp_path.unlink(missing_ok=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to decompress backup file: {exc}",
) from exc
if backend == "sqlite":
db_path = _db_path()
if db_path is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Restore is only supported for file-based SQLite databases.",
)
# Close the application DB session before replacing the file
db.close()
try:
_restore_sqlite(db_path, tmp_path)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
# Create a fresh in-memory DB from the script to validate it
try:
mem_conn = sqlite3.connect(":memory:")
mem_conn.executescript(sql_script)
mem_conn.close()
except sqlite3.Error as exc:
tmp_path.unlink(missing_ok=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Backup file contains invalid SQL: {exc}",
) from exc
elif backend == "postgresql":
db.close()
try:
_restore_postgresql(app_settings.database_url, tmp_path)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"psql binary not found is PostgreSQL client installed? ({exc})",
) from exc
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"PostgreSQL restore failed: {exc}",
) from exc
# Close the application DB session before replacing the file
db.close()
elif backend == "mysql":
db.close()
try:
_restore_mysql(app_settings.database_url, tmp_path)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"mysql binary not found is MySQL client installed? ({exc})",
) from exc
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"MySQL restore failed: {exc}",
) from exc
# Preserve the current DB before overwriting
import shutil
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Database backend '{backend}' does not support restore.",
)
bak = str(db_path) + ".pre_restore"
try:
shutil.copy2(str(db_path), bak)
except OSError as exc:
logger.warning(f"Could not create pre-restore backup at {bak}: {exc}")
try:
# Write the restored database
restore_conn = sqlite3.connect(str(db_path))
restore_conn.executescript(sql_script)
restore_conn.close()
except sqlite3.Error as exc:
# Attempt rollback
try:
if os.path.exists(bak):
shutil.copy2(bak, str(db_path))
except OSError as rollback_exc:
logger.error(f"Rollback failed; database may be corrupted: {rollback_exc}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Restore failed: {exc}",
) from exc
finally:
tmp_path.unlink(missing_ok=True)
+49
View File
@@ -31,6 +31,7 @@ from app.utils.local_auth import (
generate_token,
hash_password,
is_token_expired,
send_forgot_username_email,
send_password_reset_email,
send_verification_email,
)
@@ -79,6 +80,12 @@ class PasswordResetBody(BaseModel):
new_password_confirm: str
class ForgotUsernameBody(BaseModel):
"""Body for the forgot-username endpoint."""
email: str
# ---------------------------------------------------------------------------
# Page routes (return HTML)
# ---------------------------------------------------------------------------
@@ -107,6 +114,32 @@ async def verify_email_sent_page(request: Request) -> Any:
return templates.TemplateResponse("verify_email_sent.html", {"request": request})
@router.get("/forgot-username", include_in_schema=False)
async def forgot_username_page(request: Request) -> Any:
"""Render the forgot-username page where users can request a username reminder email."""
return templates.TemplateResponse(
"forgot_username.html",
{
"request": request,
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
)
@router.get("/forgot-password", include_in_schema=False)
async def forgot_password_page(request: Request) -> Any:
"""Render the forgot-password page where users can request a reset email."""
return templates.TemplateResponse(
"forgot_password.html",
{
"request": request,
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
)
@router.get("/reset-password", include_in_schema=False)
async def reset_password_page(request: Request) -> Any:
"""Render the password reset form page."""
@@ -337,3 +370,19 @@ async def reset_password(body: PasswordResetBody, db: DbSession) -> dict[str, st
logger.info("[SECURITY] PASSWORD_RESET_SUCCESS user=%s", user.email)
return {"message": "Password updated successfully."}
@router.post("/api/auth/forgot-username")
async def forgot_username(body: ForgotUsernameBody, db: DbSession) -> dict[str, str]:
"""Send a username reminder email.
Always returns 200 to avoid leaking whether an email is registered.
"""
user = db.query(LocalUser).filter(LocalUser.email == body.email).first()
if user:
try:
send_forgot_username_email(user.email, user.username)
except Exception as exc:
logger.warning("Failed to send forgot-username email to %s: %s", user.email, exc)
return {"message": "Username reminder sent if account exists."}
+337 -9
View File
@@ -16,12 +16,19 @@ Three separate Celery-beat entries call ``create_backup`` with the appropriate
After each backup is created ``_apply_retention`` prunes old local backups for
that tier. Remote copies are pruned by ``_prune_remote_backups`` which mirrors
the same retention limits.
Supported database backends
----------------------------
- **SQLite** dumped via Python's built-in ``sqlite3.iterdump()``; archive extension ``.db.gz``
- **PostgreSQL** dumped via ``pg_dump --format=plain``; archive extension ``.pgsql.gz``
- **MySQL / MariaDB** dumped via ``mysqldump --single-transaction``; archive extension ``.mysql.gz``
"""
import gzip
import hashlib
import logging
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
@@ -42,6 +49,13 @@ _BACKUP_TYPE_RETAIN: dict[str, str] = {
"weekly": "backup_retain_weekly",
}
#: Map of backend name → archive file extension.
_BACKEND_EXTENSIONS: dict[str, str] = {
"sqlite": ".db.gz",
"postgresql": ".pgsql.gz",
"mysql": ".mysql.gz",
}
def _backup_dir() -> Path:
"""Return (and create) the local backup directory."""
@@ -51,6 +65,14 @@ def _backup_dir() -> Path:
return path
def _db_backend() -> str:
"""Return the database backend name (e.g. ``'sqlite'``, ``'postgresql'``, ``'mysql'``)."""
from sqlalchemy.engine.url import make_url
url = make_url(settings.database_url)
return url.get_backend_name()
def _db_path() -> Path | None:
"""Return the SQLite database file path, or None for non-SQLite databases."""
from sqlalchemy.engine.url import make_url
@@ -64,6 +86,20 @@ def _db_path() -> Path | None:
return Path(db)
def _archive_ext_for_backend(backend: str) -> str:
"""Return the archive file extension for the given database backend.
Args:
backend: Backend name as returned by
``sqlalchemy.engine.url.URL.get_backend_name()`` (e.g. ``'sqlite'``).
Returns:
File extension string including the leading dot, e.g. ``'.db.gz'``.
Falls back to ``'.sql.gz'`` for unknown backends.
"""
return _BACKEND_EXTENSIONS.get(backend, ".sql.gz")
def _sha256(path: Path) -> str:
"""Return the SHA-256 hex digest of *path*."""
h = hashlib.sha256()
@@ -86,6 +122,277 @@ def _dump_sqlite(db_path: Path, dest: Path) -> None:
conn.close()
def _dump_postgresql(db_url: str, dest: Path) -> None:
"""Write a gzip-compressed ``pg_dump`` of the PostgreSQL database to *dest*.
Uses ``PGPASSWORD`` environment variable so the password is never exposed on
the process command line.
Args:
db_url: Full SQLAlchemy database URL (e.g. ``postgresql://user:pass@host/db``).
dest: Destination path for the ``.pgsql.gz`` archive.
Raises:
RuntimeError: If ``pg_dump`` exits with a non-zero return code.
FileNotFoundError: If the ``pg_dump`` binary is not found.
"""
from sqlalchemy.engine.url import make_url
url = make_url(db_url)
env = os.environ.copy()
if url.password:
env["PGPASSWORD"] = str(url.password)
# Command arguments are built from the SQLAlchemy URL (admin-configured DATABASE_URL),
# not from user-controlled input. shell=False (the default when passing a list) is used
# so there is no shell interpretation of the argument values.
cmd: list[str] = ["pg_dump", "--format=plain", "--no-password"]
if url.host:
cmd.extend(["-h", url.host])
if url.port:
cmd.extend(["-p", str(url.port)])
if url.username:
cmd.extend(["-U", url.username])
if url.database:
cmd.append(url.database)
with gzip.open(str(dest), "wb") as gz:
proc = subprocess.Popen( # noqa: S603
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
stdout = proc.stdout
if stdout is None: # pragma: no cover guaranteed by stdout=PIPE
raise RuntimeError("pg_dump produced no stdout pipe")
try:
while True:
chunk = stdout.read(65536)
if not chunk:
break
gz.write(chunk)
finally:
stdout.close()
stderr_bytes = proc.stderr.read() if proc.stderr else b""
proc.wait()
if proc.returncode != 0:
dest.unlink(missing_ok=True)
raise RuntimeError(
f"pg_dump exited with code {proc.returncode}: {stderr_bytes.decode(errors='replace').strip()}"
)
def _dump_mysql(db_url: str, dest: Path) -> None:
"""Write a gzip-compressed ``mysqldump`` of the MySQL database to *dest*.
Uses the ``MYSQL_PWD`` environment variable so the password is never exposed
on the process command line.
Args:
db_url: Full SQLAlchemy database URL
(e.g. ``mysql+pymysql://user:pass@host/db``).
dest: Destination path for the ``.mysql.gz`` archive.
Raises:
RuntimeError: If ``mysqldump`` exits with a non-zero return code.
FileNotFoundError: If the ``mysqldump`` binary is not found.
"""
from sqlalchemy.engine.url import make_url
url = make_url(db_url)
env = os.environ.copy()
if url.password:
env["MYSQL_PWD"] = str(url.password)
# Command arguments are built from the SQLAlchemy URL (admin-configured DATABASE_URL).
# shell=False (list form) prevents shell interpretation of argument values.
cmd: list[str] = ["mysqldump", "--single-transaction", "--routines", "--triggers"]
if url.host:
cmd.extend(["-h", url.host])
if url.port:
cmd.extend(["-P", str(url.port)])
if url.username:
cmd.extend(["-u", url.username])
if url.database:
cmd.append(url.database)
with gzip.open(str(dest), "wb") as gz:
proc = subprocess.Popen( # noqa: S603
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
stdout = proc.stdout
if stdout is None: # pragma: no cover guaranteed by stdout=PIPE
raise RuntimeError("mysqldump produced no stdout pipe")
try:
while True:
chunk = stdout.read(65536)
if not chunk:
break
gz.write(chunk)
finally:
stdout.close()
stderr_bytes = proc.stderr.read() if proc.stderr else b""
proc.wait()
if proc.returncode != 0:
dest.unlink(missing_ok=True)
raise RuntimeError(
f"mysqldump exited with code {proc.returncode}: {stderr_bytes.decode(errors='replace').strip()}"
)
def _restore_sqlite(db_path: Path, archive_path: Path) -> None:
"""Restore a SQLite database from a gzip-compressed SQL dump archive.
Validates the SQL by replaying it on an in-memory database before touching
the live file. Saves a ``<db_path>.pre_restore`` rollback copy first.
Args:
db_path: Path to the live SQLite database file to overwrite.
archive_path: Path to the ``.db.gz`` gzip-compressed SQL dump.
Raises:
ValueError: If the archive cannot be decompressed or contains invalid SQL.
RuntimeError: If writing the restored database fails.
"""
import shutil
import sqlite3
# Decompress and read SQL statements
try:
with gzip.open(str(archive_path), "rt", encoding="utf-8") as gz:
sql_script = gz.read()
except Exception as exc:
raise ValueError(f"Failed to decompress backup file: {exc}") from exc
# Validate by replaying on an in-memory database
try:
mem_conn = sqlite3.connect(":memory:")
mem_conn.executescript(sql_script)
mem_conn.close()
except sqlite3.Error as exc:
raise ValueError(f"Backup file contains invalid SQL: {exc}") from exc
# Preserve the current DB before overwriting
bak = str(db_path) + ".pre_restore"
try:
shutil.copy2(str(db_path), bak)
except OSError as exc:
logger.warning(f"Could not create pre-restore backup at {bak}: {exc}")
try:
restore_conn = sqlite3.connect(str(db_path))
restore_conn.executescript(sql_script)
restore_conn.close()
except sqlite3.Error as exc:
# Attempt rollback to the pre-restore copy
try:
if os.path.exists(bak):
shutil.copy2(bak, str(db_path))
except OSError as rollback_exc:
logger.error(f"Rollback failed; database may be corrupted: {rollback_exc}")
raise RuntimeError(f"SQLite restore failed: {exc}") from exc
def _restore_postgresql(db_url: str, archive_path: Path) -> None:
"""Restore a PostgreSQL database from a gzip-compressed SQL dump archive.
Pipes the decompressed dump to ``psql``. Uses ``PGPASSWORD`` so the
password is never exposed on the process command line.
Args:
db_url: Full SQLAlchemy database URL.
archive_path: Path to the ``.pgsql.gz`` gzip-compressed ``pg_dump`` archive.
Raises:
RuntimeError: If ``psql`` exits with a non-zero return code.
FileNotFoundError: If the ``psql`` binary is not found.
"""
from sqlalchemy.engine.url import make_url
url = make_url(db_url)
env = os.environ.copy()
if url.password:
env["PGPASSWORD"] = str(url.password)
# Command arguments are built from the SQLAlchemy URL (admin-configured DATABASE_URL).
# shell=False (list form) prevents shell interpretation of argument values.
cmd: list[str] = ["psql", "--no-password"]
if url.host:
cmd.extend(["-h", url.host])
if url.port:
cmd.extend(["-p", str(url.port)])
if url.username:
cmd.extend(["-U", url.username])
if url.database:
cmd.append(url.database)
with gzip.open(str(archive_path), "rb") as gz:
proc = subprocess.Popen( # noqa: S603
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
_, stderr_bytes = proc.communicate(input=gz.read())
if proc.returncode != 0:
raise RuntimeError(f"psql exited with code {proc.returncode}: {stderr_bytes.decode(errors='replace').strip()}")
def _restore_mysql(db_url: str, archive_path: Path) -> None:
"""Restore a MySQL database from a gzip-compressed SQL dump archive.
Pipes the decompressed dump to ``mysql``. Uses the ``MYSQL_PWD``
environment variable so the password is never exposed on the command line.
Args:
db_url: Full SQLAlchemy database URL.
archive_path: Path to the ``.mysql.gz`` gzip-compressed ``mysqldump`` archive.
Raises:
RuntimeError: If ``mysql`` exits with a non-zero return code.
FileNotFoundError: If the ``mysql`` binary is not found.
"""
from sqlalchemy.engine.url import make_url
url = make_url(db_url)
env = os.environ.copy()
if url.password:
env["MYSQL_PWD"] = str(url.password)
# Command arguments are built from the SQLAlchemy URL (admin-configured DATABASE_URL).
# shell=False (list form) prevents shell interpretation of argument values.
cmd: list[str] = ["mysql"]
if url.host:
cmd.extend(["-h", url.host])
if url.port:
cmd.extend(["-P", str(url.port)])
if url.username:
cmd.extend(["-u", url.username])
if url.database:
cmd.append(url.database)
with gzip.open(str(archive_path), "rb") as gz:
proc = subprocess.Popen( # noqa: S603
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
_, stderr_bytes = proc.communicate(input=gz.read())
if proc.returncode != 0:
raise RuntimeError(f"mysql exited with code {proc.returncode}: {stderr_bytes.decode(errors='replace').strip()}")
def _apply_retention(backup_type: str, db: object) -> None:
"""Delete local backups beyond the retention limit for *backup_type*.
@@ -314,6 +621,11 @@ def _email_backup(archive_path: Path, filename: str) -> None:
def create_backup(self, backup_type: str = "hourly") -> dict:
"""Create a database backup archive and apply retention.
Supports SQLite (``.db.gz``), PostgreSQL (``.pgsql.gz``), and
MySQL / MariaDB (``.mysql.gz``) databases. The native dump tool for the
configured backend (``sqlite3``, ``pg_dump``, or ``mysqldump``) must be
available on the worker's ``PATH``.
Args:
backup_type: ``"hourly"``, ``"daily"``, or ``"weekly"``.
@@ -327,19 +639,27 @@ def create_backup(self, backup_type: str = "hourly") -> dict:
logger.debug("Backup is disabled; skipping create_backup task.")
return {"status": "disabled"}
backend = _db_backend()
ext = _archive_ext_for_backend(backend)
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
filename = f"backup_{backup_type}_{ts}.db.gz"
filename = f"backup_{backup_type}_{ts}{ext}"
archive_path = _backup_dir() / filename
db_path = _db_path()
if db_path is None:
logger.warning("Backup task skipped: non-SQLite databases are not supported for file-based backups.")
# SQLite: verify the database file exists before attempting to dump it
db_path: Path | None = None
if backend == "sqlite":
db_path = _db_path()
if db_path is None:
logger.warning("Backup task skipped: in-memory SQLite databases are not supported.")
return {"status": "unsupported_db"}
if not db_path.exists():
logger.error(f"Database file not found: {db_path}")
return {"status": "error", "detail": f"DB file missing: {db_path}"}
elif backend not in ("postgresql", "mysql"):
logger.warning(f"Backup task skipped: unsupported database backend '{backend}'.")
return {"status": "unsupported_db"}
if not db_path.exists():
logger.error(f"Database file not found: {db_path}")
return {"status": "error", "detail": f"DB file missing: {db_path}"}
status = "ok"
checksum: str | None = None
size_bytes = 0
@@ -347,7 +667,15 @@ def create_backup(self, backup_type: str = "hourly") -> dict:
remote_path: str | None = None
try:
_dump_sqlite(db_path, archive_path)
if backend == "sqlite":
# db_path is guaranteed non-None: we returned early if it were None
if db_path is None: # pragma: no cover
return {"status": "error", "detail": "db_path unexpectedly None"}
_dump_sqlite(db_path, archive_path)
elif backend == "postgresql":
_dump_postgresql(settings.database_url, archive_path)
elif backend == "mysql":
_dump_mysql(settings.database_url, archive_path)
size_bytes = archive_path.stat().st_size
checksum = _sha256(archive_path)
logger.info(f"Created {backup_type} backup: {archive_path} ({size_bytes:,} bytes)")
+35
View File
@@ -164,6 +164,41 @@ def send_password_reset_email(email: str, username: str, token: str, base_url: s
_smtp_send(subject, html_body, plain_body, email)
def send_forgot_username_email(email: str, username: str) -> None:
"""Send an email reminding the user of their username.
Args:
email: Recipient email address.
username: The user's username to include in the message.
"""
subject = "Your DocuElevate username"
html_body = f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="font-family:Arial,sans-serif;background:#f4f4f5;margin:0;padding:32px;">
<div style="max-width:480px;margin:0 auto;background:#fff;border-radius:12px;padding:32px;box-shadow:0 2px 8px rgba(0,0,0,.08);">
<h1 style="color:#4f46e5;font-size:24px;margin-bottom:8px;">Your Username</h1>
<p style="color:#374151;">You requested a reminder of your DocuElevate username.</p>
<div style="text-align:center;margin:32px 0;background:#f3f4f6;border-radius:8px;padding:20px;">
<p style="color:#6b7280;font-size:13px;margin-bottom:4px;">Your username is:</p>
<p style="color:#111827;font-size:22px;font-weight:700;font-family:monospace;">{username}</p>
</div>
<p style="color:#374151;font-size:14px;">You can sign in using your username <strong>or</strong> your email address.</p>
<p style="color:#6b7280;font-size:13px;margin-top:16px;">If you did not request this reminder, you can safely ignore this email.</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">DocuElevate &middot; Intelligent Document Processing</p>
</div>
</body>
</html>"""
plain_body = (
f"You requested a reminder of your DocuElevate username.\n\n"
f"Your username is: {username}\n\n"
"You can sign in using your username or your email address.\n\n"
"If you did not request this, please ignore this email."
)
_smtp_send(subject, html_body, plain_body, email)
def build_session_user(user: object) -> dict:
"""Build the session user dict for a LocalUser, matching the OAuth session format.