Compare commits

...

13 Commits

Author SHA1 Message Date
google-labs-jules[bot] 1fd3e745cf Fix failing dependencies check due to temporary issue
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-23 16:29:22 +00:00
github-actions[bot] 084171395d docs(changelog): update changelog [skip ci] 2026-03-23 12:17:55 +00:00
Christian Krakau-Louis 958b195e79 Merge pull request #806 from christianlouis/alert-autofix-400
Potential fix for code scanning alert no. 400: Clear-text logging of sensitive information
2026-03-23 13:17:29 +01:00
Christian Krakau-Louis c5ef1ec50c Potential fix for code scanning alert no. 400: Clear-text logging of sensitive information
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-23 13:17:19 +01:00
github-actions[bot] ef897f660d chore(release): update build metadata files [skip ci] 2026-03-22 18:47:10 +00:00
semantic-release 6cb9feacab 0.172.1
Automatically generated by python-semantic-release
2026-03-22 18:47:07 +00:00
Christian Krakau-Louis 76c0e91500 Merge pull request #804 from christianlouis/copilot/enable-qr-code-upload
fix: social login providers load from DB without restart; connections page shows accurate linked status
2026-03-22 19:46:48 +01:00
github-actions[bot] 78077fa8c7 chore(release): update build metadata files [skip ci] 2026-03-22 18:24:56 +00:00
semantic-release 242846aa9c 0.172.0
Automatically generated by python-semantic-release
2026-03-22 18:24:52 +00:00
Christian Krakau-Louis 868613ac49 Merge pull request #802 from christianlouis/copilot/migrate-tailscale-to-3-x
feat(ui): migrate Tailwind CSS from v2 CDN to compiled v3 production build
2026-03-22 19:24:31 +01:00
copilot-swe-agent[bot] 14b3031e63 feat(ui): replace Tailwind CSS CDN with compiled v3 production build
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/85d2244d-170a-48d3-8f6b-b4c124a49ed9
2026-03-22 18:07:32 +00:00
copilot-swe-agent[bot] 1d7df13c94 feat(ui): migrate Tailwind CSS from v2 CDN to v3 Play CDN (interim step)
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/85d2244d-170a-48d3-8f6b-b4c124a49ed9
2026-03-22 17:50:11 +00:00
copilot-swe-agent[bot] 48a303d498 Initial plan 2026-03-22 17:37:51 +00:00
41 changed files with 1893 additions and 549 deletions
+3
View File
@@ -200,3 +200,6 @@ cython_debug/
# Build metadata files - generated at build time
GIT_SHA
RUNTIME_INFO
# Frontend build tooling
frontend/node_modules/
+4 -8
View File
@@ -1,8 +1,4 @@
## 2024-05-24 - SSRF in WebDAV connection test
**Vulnerability:** The `_test_webdav_connection` function had a custom SSRF check that failed to resolve DNS names, allowing attackers to bypass the check by providing a domain that resolves to an internal IP (e.g., `127.0.0.1`).
**Learning:** DNS resolution is required for robust SSRF protection when validating URLs provided by users.
**Prevention:** Use a centralized `is_private_ip` function (now in `app/utils/network.py`) that resolves the hostname to its IPs and checks if any are private.
## 2026-03-22 - B310: urllib.request.urlopen replaced with httpx
**Vulnerability:** The `_test_webdav_connection` function used `urllib.request.urlopen`, which natively supports dangerous schemes like `file://` or `ftp://` and follows redirects by default, potentially allowing SSRF bypasses or Local File Inclusion.
**Learning:** `urllib.request` should be avoided for user-supplied URLs. Even when URL schemes are manually validated, `urllib`'s default redirect following behavior can bypass SSRF protections (e.g. redirecting to `127.0.0.1`).
**Prevention:** Use a modern, safer HTTP client like `httpx` with `follow_redirects=False` when testing user-provided URLs.
## 2025-05-18 - [SSRF Bypass via DNS Resolution Failure]
**Vulnerability:** The `is_private_ip` function in `app/utils/network.py` failed open (returned `False`) when a hostname could not be resolved (`socket.gaierror`).
**Learning:** This fail-open pattern was originally added to allow external domains in tests, but in production, it created a severe SSRF risk. An attacker could bypass SSRF protections by providing a URL that fails to resolve during the security check but resolves later (DNS rebinding), or by exploiting internal routing behaviors via unresolvable addresses.
**Prevention:** Always fail securely in network authorization functions. If a domain cannot be resolved to verify its safety, the request must be blocked (`return True` / default-deny). Tests should mock DNS resolution correctly instead of compromising production security logic.
+1 -1
View File
@@ -1 +1 @@
2026-03-22T17:46:27Z
2026-03-23T14:11:22Z
+44
View File
@@ -10,6 +10,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- version list -->
## Unreleased
## v0.172.2 (2026-03-23)
### Bug Fixes
- Adapt TemplateResponse calls to Starlette 1.0 new-style API
([`c4e10be`](https://github.com/christianlouis/DocuElevate/commit/c4e10bee5e096e71a5bc4fac4928f69e5c04f2fb))
- Update test assertions and lint fixes for Starlette 1.0 TemplateResponse API
([`93629ff`](https://github.com/christianlouis/DocuElevate/commit/93629ff44083d43f79fdd49431457023e53d13e4))
- **build**: Remove --omit=dev from npm ci in Dockerfile frontend-builder stage
([`b4e0067`](https://github.com/christianlouis/DocuElevate/commit/b4e0067a27e2fb161349bd38c6d3b3f3bcb86972))
### Documentation
- **changelog**: Update changelog [skip ci]
([`0841713`](https://github.com/christianlouis/DocuElevate/commit/084171395d1076c716aa500a516118db49468ff5))
## Unreleased
## v0.172.1 (2026-03-22)
### Bug Fixes
- Social login providers now work from DB config without restart
([`0c7ea67`](https://github.com/christianlouis/DocuElevate/commit/0c7ea6748da554c80ef9af1b709c08aba49174e6))
## v0.172.0 (2026-03-22)
### Features
- **ui**: Migrate Tailwind CSS from v2 CDN to v3 Play CDN (interim step)
([`1d7df13`](https://github.com/christianlouis/DocuElevate/commit/1d7df13c943cc9138dc3ed514f9ab81d861bfbac))
- **ui**: Replace Tailwind CSS CDN with compiled v3 production build
([`14b3031`](https://github.com/christianlouis/DocuElevate/commit/14b3031e63e8645c4048dd73a594e9a53a919c17))
## v0.171.3 (2026-03-22)
### Bug Fixes
+17 -2
View File
@@ -27,7 +27,20 @@ RUN pip install --no-cache-dir -r requirements.txt \
&& find /opt/venv -type f -name "*.pyc" -delete \
&& find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
# ── Stage 2: Documentation builder ──────────────────────────────────────────
# ── Stage 2: Frontend asset builder (Tailwind CSS) ──────────────────────────
FROM node:20-alpine AS frontend-builder
WORKDIR /frontend
# Install dependencies first (layer-cached unless package.json/lockfile changes)
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# Copy source files and compile Tailwind CSS
COPY frontend/ ./
RUN npm run build
# ── Stage 3: Documentation builder ──────────────────────────────────────────
FROM python:3.14.3-slim AS docs-builder
WORKDIR /docs
@@ -43,7 +56,7 @@ COPY mkdocs.yml /docs/mkdocs.yml
# Build the static documentation site
RUN mkdocs build --config-file /docs/mkdocs.yml --site-dir /docs/docs_build
# ── Stage 3: Runtime image ───────────────────────────────────────────────────
# ── Stage 4: Runtime image ───────────────────────────────────────────────────
FROM python:3.14.3-slim
WORKDIR /app
@@ -68,6 +81,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Copy application code
COPY ./app /app/app
COPY ./frontend /app/frontend
# Overlay compiled Tailwind CSS from the frontend build stage
COPY --from=frontend-builder /frontend/static/styles.css /app/frontend/static/styles.css
COPY ./migrations /app/migrations
COPY ./alembic.ini /app/alembic.ini
COPY ./LICENSE /app/LICENSE
+1 -1
View File
@@ -1 +1 @@
720c9c1
34457f9
+6 -6
View File
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
Version: 0.171.3
Build Date: 2026-03-22T17:46:27Z
Git Commit: 720c9c11b05a7af6ac33478ecab6cc80c24c83e1
Git Short SHA: 720c9c1
Version: 0.172.2
Build Date: 2026-03-23T14:11:22Z
Git Commit: 34457f977509ce145b7411e83982a96b0fd0e33e
Git Short SHA: 34457f9
Git Branch: main
Commit Date: 2026-03-22T18:46:06+01:00
Build Timestamp: 2026-03-22T17:46:27Z
Commit Date: 2026-03-23T15:10:59+01:00
Build Timestamp: 2026-03-23T14:11:22Z
==============================
+1 -1
View File
@@ -1 +1 @@
0.171.3
0.172.2
+1 -1
View File
@@ -260,7 +260,7 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)) -> dic
@require_login
async def billing_success(request: Request) -> Any:
"""Show a success page after a completed Stripe Checkout."""
return _templates.TemplateResponse("billing_success.html", {"request": request})
return _templates.TemplateResponse(request, "billing_success.html")
# ---------------------------------------------------------------------------
+9 -9
View File
@@ -101,9 +101,9 @@ async def signup_page(request: Request) -> Any:
if not settings.allow_local_signup:
return RedirectResponse(url="/login?error=Registration+is+not+enabled", status_code=302)
return templates.TemplateResponse(
request,
"signup.html",
{
"request": request,
context={
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
@@ -113,16 +113,16 @@ async def signup_page(request: Request) -> Any:
@router.get("/verify-email-sent", include_in_schema=False)
async def verify_email_sent_page(request: Request) -> Any:
"""Render the verify-email-sent confirmation page."""
return templates.TemplateResponse("verify_email_sent.html", {"request": request})
return templates.TemplateResponse(request, "verify_email_sent.html")
@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(
request,
"forgot_username.html",
{
"request": request,
context={
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
@@ -133,9 +133,9 @@ async def forgot_username_page(request: Request) -> Any:
async def forgot_password_page(request: Request) -> Any:
"""Render the forgot-password page where users can request a reset email."""
return templates.TemplateResponse(
request,
"forgot_password.html",
{
"request": request,
context={
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
@@ -147,9 +147,9 @@ async def reset_password_page(request: Request) -> Any:
"""Render the password reset form page."""
token = request.query_params.get("token", "")
return templates.TemplateResponse(
request,
"password_reset_form.html",
{
"request": request,
context={
"token": token,
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
+3 -3
View File
@@ -280,7 +280,7 @@ def _setup_social_providers() -> None:
"icon": "fas fa-sign-in-alt",
"color": "indigo",
}
logger.info("Social login provider registered: Generic OAuth2 (%s)", _generic_name)
logger.info("Social login provider registered: Generic OAuth2")
else:
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
@@ -536,9 +536,9 @@ async def login(request: Request):
return RedirectResponse(url="/oauth-login", status_code=status.HTTP_302_FOUND)
return templates.TemplateResponse(
request,
"login.html",
{
"request": request,
context={
"error": error,
"message": message,
"show_oauth": show_oauth,
+4 -5
View File
@@ -424,15 +424,13 @@ async def http_exception_handler(request: Request, exc: HTTPException):
# For frontend routes, return appropriate HTML templates
# Handle 404 errors with a custom template
if exc.status_code == 404:
return _error_templates.TemplateResponse(
"404.html", {"request": request}, status_code=status.HTTP_404_NOT_FOUND
)
return _error_templates.TemplateResponse(request, "404.html", status_code=status.HTTP_404_NOT_FOUND)
# For other HTTP errors, we could create specific templates or use a generic one
# For now, return a simple error page
return _error_templates.TemplateResponse(
request,
"404.html", # Reuse 404 template for other errors, or create a generic error template
{"request": request},
status_code=exc.status_code,
)
@@ -452,8 +450,9 @@ async def custom_500_handler(request: Request, exc: Exception):
# Serve the 500 template for non-API routes
return _error_templates.TemplateResponse(
request,
"500.html",
{"request": request, "exc": exc},
context={"exc": exc},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
+141 -157
View File
@@ -1,157 +1,141 @@
#!/usr/bin/env python3
import logging
import os
import requests
from requests.auth import HTTPBasicAuth
from app.celery_app import celery
from app.config import settings
from app.tasks.retry_config import UploadTaskWithRetry
from app.utils import log_task_progress
from app.utils.filename_utils import extract_remote_path, get_unique_filename
logger = logging.getLogger(__name__)
@celery.task(base=UploadTaskWithRetry, bind=True)
def upload_to_nextcloud(self, file_path: str, file_id: int = None, folder_override: str = None):
"""
Upload a file to Nextcloud WebDAV.
Args:
file_path: Path to the file to upload
file_id: Optional file ID to associate with logs
"""
task_id = self.request.id
logger.info(f"[{task_id}] Starting Nextcloud upload: {file_path}")
log_task_progress(
task_id,
"upload_to_nextcloud",
"in_progress",
f"Uploading to Nextcloud: {os.path.basename(file_path)}",
file_id=file_id,
)
if not os.path.exists(file_path):
error_msg = f"File not found: {file_path}"
logger.error(f"[{task_id}] {error_msg}")
log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id)
raise FileNotFoundError(error_msg)
# For Nextcloud, we need to check for 'nextcloud_upload_url' instead of 'nextcloud_url'
# This is what's shown in your env view
if not (
getattr(settings, "nextcloud_upload_url", None)
and getattr(settings, "nextcloud_username", None)
and getattr(settings, "nextcloud_password", None)
):
logger.info(f"[{task_id}] Nextcloud upload skipped: Missing configuration")
log_task_progress(task_id, "upload_to_nextcloud", "success", "Skipped: Not configured", file_id=file_id)
return {"status": "Skipped", "reason": "Nextcloud settings not configured"}
filename = os.path.basename(file_path)
try:
# Prepare WebDAV URL - use nextcloud_upload_url instead of nextcloud_url
webdav_url = settings.nextcloud_upload_url
if not webdav_url.endswith("/"):
webdav_url += "/"
# Calculate remote path based on local file structure
remote_base = (
folder_override if folder_override is not None else (getattr(settings, "nextcloud_folder", "") or "")
)
remote_path = extract_remote_path(file_path, settings.workdir, remote_base)
full_url = f"{webdav_url}/{remote_path}"
# Remove any double slashes (except in http://)
full_url = full_url.replace("://", "$PLACEHOLDER$")
while "//" in full_url:
full_url = full_url.replace("//", "/")
full_url = full_url.replace("$PLACEHOLDER$", "://")
# Function to check if file exists in Nextcloud
def check_exists_in_nextcloud(path):
check_url = f"{webdav_url}{os.path.dirname(path)}"
try:
response = requests.request(
"PROPFIND",
check_url,
auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password),
headers={"Depth": "1"},
timeout=10,
)
return path in response.text
except Exception:
# If we can't check, assume it doesn't exist
return False
# Check for potential file collision and get a unique name if needed
remote_path = get_unique_filename(remote_path, check_exists_in_nextcloud)
full_url = f"{webdav_url}/{remote_path}"
# Fix double slashes again
full_url = full_url.replace("://", "$PLACEHOLDER$")
while "//" in full_url:
full_url = full_url.replace("//", "/")
full_url = full_url.replace("$PLACEHOLDER$", "://")
# Create necessary parent folders
parent_dirs = os.path.dirname(remote_path)
if parent_dirs:
current_path = ""
for folder in parent_dirs.split("/"):
if not folder:
continue
current_path += f"{folder}/"
mkdir_url = f"{webdav_url}/{current_path}"
# Fix double slashes
mkdir_url = mkdir_url.replace("://", "$PLACEHOLDER$")
while "//" in mkdir_url:
mkdir_url = mkdir_url.replace("//", "/")
mkdir_url = mkdir_url.replace("$PLACEHOLDER$", "://")
requests.request(
"MKCOL",
mkdir_url,
auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password),
timeout=10,
)
# Upload the file
logger.info(f"[{task_id}] Uploading {filename} to Nextcloud at {full_url}")
log_task_progress(task_id, "upload_file", "in_progress", f"Uploading to {remote_path}", file_id=file_id)
with open(file_path, "rb") as file_data:
response = requests.put(
full_url,
data=file_data,
auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password),
headers={"Content-Type": "application/octet-stream"},
timeout=settings.http_request_timeout, # Use configured timeout for large files
)
if response.status_code in (201, 204): # Created or No Content
logger.info(f"[{task_id}] Successfully uploaded {filename} to Nextcloud at {remote_path}")
log_task_progress(
task_id, "upload_to_nextcloud", "success", f"Uploaded to Nextcloud: {remote_path}", file_id=file_id
)
return {
"status": "Completed",
"file_path": file_path,
"nextcloud_path": remote_path,
"response_code": response.status_code,
}
else:
error_msg = f"Failed to upload {filename} to Nextcloud: {response.status_code} - {response.text}"
logger.error(f"[{task_id}] {error_msg}")
log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id)
raise Exception(error_msg)
except Exception as e:
error_msg = f"Failed to upload {filename} to Nextcloud: {str(e)}"
logger.error(f"[{task_id}] {error_msg}")
log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id)
raise Exception(error_msg)
#!/usr/bin/env python3
import logging
import os
import requests
from requests.auth import HTTPBasicAuth
from app.celery_app import celery
from app.config import settings
from app.tasks.retry_config import UploadTaskWithRetry
from app.utils import log_task_progress
from app.utils.filename_utils import extract_remote_path, get_unique_filename
from app.utils.network import join_url
logger = logging.getLogger(__name__)
@celery.task(base=UploadTaskWithRetry, bind=True)
def upload_to_nextcloud(self, file_path: str, file_id: int = None, folder_override: str = None):
"""
Upload a file to Nextcloud WebDAV.
Args:
file_path: Path to the file to upload
file_id: Optional file ID to associate with logs
"""
task_id = self.request.id
logger.info(f"[{task_id}] Starting Nextcloud upload: {file_path}")
log_task_progress(
task_id,
"upload_to_nextcloud",
"in_progress",
f"Uploading to Nextcloud: {os.path.basename(file_path)}",
file_id=file_id,
)
if not os.path.exists(file_path):
error_msg = f"File not found: {file_path}"
logger.error(f"[{task_id}] {error_msg}")
log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id)
raise FileNotFoundError(error_msg)
# For Nextcloud, we need to check for 'nextcloud_upload_url' instead of 'nextcloud_url'
# This is what's shown in your env view
if not (
getattr(settings, "nextcloud_upload_url", None)
and getattr(settings, "nextcloud_username", None)
and getattr(settings, "nextcloud_password", None)
):
logger.info(f"[{task_id}] Nextcloud upload skipped: Missing configuration")
log_task_progress(task_id, "upload_to_nextcloud", "success", "Skipped: Not configured", file_id=file_id)
return {"status": "Skipped", "reason": "Nextcloud settings not configured"}
filename = os.path.basename(file_path)
try:
# Prepare WebDAV URL - use nextcloud_upload_url instead of nextcloud_url
webdav_url = settings.nextcloud_upload_url
if not webdav_url.endswith("/"):
webdav_url += "/"
# Calculate remote path based on local file structure
remote_base = (
folder_override if folder_override is not None else (getattr(settings, "nextcloud_folder", "") or "")
)
remote_path = extract_remote_path(file_path, settings.workdir, remote_base)
full_url = join_url(webdav_url, remote_path)
# Function to check if file exists in Nextcloud
def check_exists_in_nextcloud(path):
check_url = join_url(webdav_url, os.path.dirname(path))
try:
response = requests.request(
"PROPFIND",
check_url,
auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password),
headers={"Depth": "1"},
timeout=10,
)
return path in response.text
except Exception:
# If we can't check, assume it doesn't exist
return False
# Check for potential file collision and get a unique name if needed
remote_path = get_unique_filename(remote_path, check_exists_in_nextcloud)
full_url = join_url(webdav_url, remote_path)
# Create necessary parent folders
parent_dirs = os.path.dirname(remote_path)
if parent_dirs:
current_path = ""
for folder in parent_dirs.split("/"):
if not folder:
continue
current_path += f"{folder}/"
mkdir_url = join_url(webdav_url, current_path)
requests.request(
"MKCOL",
mkdir_url,
auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password),
timeout=10,
)
# Upload the file
logger.info(f"[{task_id}] Uploading {filename} to Nextcloud at {full_url}")
log_task_progress(task_id, "upload_file", "in_progress", f"Uploading to {remote_path}", file_id=file_id)
with open(file_path, "rb") as file_data:
response = requests.put(
full_url,
data=file_data,
auth=HTTPBasicAuth(settings.nextcloud_username, settings.nextcloud_password),
headers={"Content-Type": "application/octet-stream"},
timeout=settings.http_request_timeout, # Use configured timeout for large files
)
if response.status_code in (201, 204): # Created or No Content
logger.info(f"[{task_id}] Successfully uploaded {filename} to Nextcloud at {remote_path}")
log_task_progress(
task_id, "upload_to_nextcloud", "success", f"Uploaded to Nextcloud: {remote_path}", file_id=file_id
)
return {
"status": "Completed",
"file_path": file_path,
"nextcloud_path": remote_path,
"response_code": response.status_code,
}
else:
error_msg = f"Failed to upload {filename} to Nextcloud: {response.status_code} - {response.text}"
logger.error(f"[{task_id}] {error_msg}")
log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id)
raise Exception(error_msg)
except Exception as e:
error_msg = f"Failed to upload {filename} to Nextcloud: {str(e)}"
logger.error(f"[{task_id}] {error_msg}")
log_task_progress(task_id, "upload_to_nextcloud", "failure", error_msg, file_id=file_id)
raise Exception(error_msg)
+18 -5
View File
@@ -27,8 +27,21 @@ def is_private_ip(hostname: str) -> bool:
return True
return False
except (socket.gaierror, socket.error):
# Cannot resolve - allow for testing/development
# In production, DNS should work properly
# Log this for debugging
logger.warning(f"Could not resolve hostname: {hostname}")
return False # Changed from True to False to allow external domains in tests
# Cannot resolve.
# Fail securely: block unresolved domains to prevent DNS rebinding
# and SSRF bypasses via unresolvable addresses.
logger.warning(f"Could not resolve hostname (blocking securely): {hostname}")
return True
def join_url(base: str, *parts: str) -> str:
"""
Safely join a base URL and multiple path parts.
Handles double slashes while preserving the protocol '://'.
"""
url = "/".join([base, *parts])
url = url.replace("://", "$PLACEHOLDER$")
while "//" in url:
url = url.replace("//", "/")
url = url.replace("$PLACEHOLDER$", "://")
return url
+29 -5
View File
@@ -162,12 +162,36 @@ def _inject_global_context(ctx: dict) -> None:
def template_response_with_version(*args, **kwargs):
"""Wrapper for TemplateResponse to include version and CSRF token in all templates"""
# If context dict is provided, add version to it
if len(args) >= 2 and isinstance(args[1], dict):
_inject_global_context(args[1])
elif "context" in kwargs and isinstance(kwargs["context"], dict):
"""Wrapper for TemplateResponse to include version and CSRF token in all templates.
Handles both old-style and new-style Starlette TemplateResponse calls:
- Old-style (Starlette <1.0): TemplateResponse(name, {"request": req, ...}, ...)
- New-style (Starlette 1.0+): TemplateResponse(request, name, context={...}, ...)
"""
if len(args) >= 1 and isinstance(args[0], str):
# Old-style call: first positional arg is the template name (string).
# Convert to new-style: (request, name, context=..., ...)
name = args[0]
if len(args) >= 2 and isinstance(args[1], dict):
context = args[1]
# Old-style may have status_code as 3rd positional arg
if len(args) >= 3 and "status_code" not in kwargs:
kwargs["status_code"] = args[2]
else:
context = kwargs.pop("context", {})
request_obj = context.pop("request", None)
if request_obj is not None:
context["request"] = request_obj
_inject_global_context(context)
if request_obj is not None:
return original_template_response(request_obj, name, context=context, **kwargs)
return original_template_response(name, context=context, **kwargs)
# New-style call: (request, name, context=..., ...)
if "context" in kwargs and isinstance(kwargs["context"], dict):
_inject_global_context(kwargs["context"])
elif len(args) >= 3 and isinstance(args[2], dict):
_inject_global_context(args[2])
return original_template_response(*args, **kwargs)
+2 -1
View File
@@ -23,6 +23,7 @@ templates = Jinja2Templates(directory=str(_templates_dir))
async def shared_link_view(request: Request, token: str):
"""Render the public share landing page for a given token."""
return templates.TemplateResponse(
request,
"shared_link_view.html",
{"request": request, "token": token},
context={"token": token},
)
+1 -1
View File
@@ -792,7 +792,7 @@ SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self'; style-src 'sel
SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline';"
```
**Note:** The default policy includes `'unsafe-inline'` for compatibility with Tailwind CSS and inline JavaScript. For stricter security, use nonces or hashes.
**Note:** The default policy includes `'unsafe-inline'` for compatibility with inline JavaScript. Tailwind CSS v3 is compiled at build time into a static file served from `'self'`, so no external style CDN is needed.
#### X-Frame-Options
+1 -1
View File
@@ -157,7 +157,7 @@ Recommended headers to configure at the proxy level:
#### Content-Security-Policy Notes
DocuElevate's frontend uses Tailwind CSS loaded from CDN in development mode. In production, ensure your CSP allows loading scripts and styles from your configured static file origin. A starting point:
DocuElevate's frontend uses Tailwind CSS v3 compiled at Docker build time. No external CDN requests are needed for CSS. In production, your CSP does not need to allow any external style sources beyond your own static file origin. A starting point:
```
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
+329
View File
@@ -0,0 +1,329 @@
/* frontend/input.css
* Tailwind CSS v3 source file.
* Edit this file (not static/styles.css) — the compiled output is
* generated by running: npm run build (inside the frontend/ directory)
*/
/* ── Tailwind layers ──────────────────────────────────────────────────────── */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Custom utilities ─────────────────────────────────────────────────────── */
/* =============================================================
ACCESSIBILITY
Skip-to-content link, focus indicators, and screen-reader-only
utility class following WCAG 2.1 Level AA requirements.
============================================================= */
/* Skip-to-content link: visible only on keyboard focus */
.skip-link {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
z-index: 9999;
padding: 0.75rem 1.5rem;
background-color: #1d4ed8;
color: #ffffff;
font-weight: 600;
text-decoration: none;
border-radius: 0 0 0.375rem 0;
}
.skip-link:focus {
position: fixed;
top: 0;
left: 0;
width: auto;
height: auto;
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Enhanced focus-visible indicators for keyboard navigation (WCAG 2.4.7) */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Screen-reader-only utility (visually hidden, accessible to AT) */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
body {
/* Your global overrides can go here if needed */
}
.material-symbols-light--folder-managed-outline {
display: inline-block;
width: 96px;
height: 96px;
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m17.212 20.404l-.108-.885q-.57-.125-.938-.33q-.368-.204-.7-.577l-.835.334l-.539-.815l.689-.577q-.165-.531-.165-1.035t.165-1.034l-.689-.577l.539-.816l.835.335q.332-.393.7-.588q.369-.195.938-.32l.108-.885h1l.107.885q.57.125.938.32t.7.588l.835-.335l.539.816l-.689.576q.166.531.166 1.035t-.166 1.035l.689.577l-.539.815l-.834-.335q-.333.373-.701.578q-.369.205-.938.33l-.107.885zm.5-1.731q.882 0 1.518-.635q.636-.636.636-1.519t-.636-1.518t-1.518-.636t-1.519.636t-.635 1.518t.635 1.519t1.518.635M4 18V6v4.435V10zm.616 1q-.691 0-1.153-.462T3 17.384V6.616q0-.691.463-1.153T4.615 5h4.981l2 2h7.789q.69 0 1.153.463T21 8.616v2.294q-.238-.152-.479-.265q-.24-.112-.521-.21v-1.82q0-.269-.173-.442T19.385 8h-8.19l-2-2h-4.58q-.269 0-.442.173T4 6.616v10.769q0 .269.173.442t.443.173h6.748q.055.275.131.515t.186.485z'/%3E%3C/svg%3E");
background-color: currentColor;
-webkit-mask-image: var(--svg);
mask-image: var(--svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
/* Ensure pagination wraps properly on small screens */
.pagination {
flex-wrap: wrap;
gap: 0.5rem;
}
.pagination-buttons {
flex-wrap: wrap;
}
/* Ensure filter items stack on very small screens */
@media (max-width: 480px) {
.filter-group {
flex-direction: column;
}
.filter-item {
min-width: unset;
width: 100%;
}
}
/* =============================================================
DARK MODE
Activated by "dark" class on <html> element.
Toggled by the navbar button; preference stored in localStorage.
Falls back to the server-side ui_default_color_scheme setting,
then to the OS prefers-color-scheme media query.
WCAG AA contrast ratios verified for all text/background pairs.
============================================================= */
/* Tell the browser we support both colour schemes */
html { color-scheme: light; }
html.dark { color-scheme: dark; }
/* ---- Base / Body ---- */
html.dark body { background-color: #111827; color: #e5e7eb; }
html.dark .bg-gray-50 { background-color: #111827; }
html.dark .bg-white { background-color: #1f2937; }
html.dark .bg-gray-100 { background-color: #374151; }
html.dark .bg-gray-200 { background-color: #4b5563; }
/* ---- Text colours ---- */
html.dark .text-gray-900 { color: #f9fafb; }
html.dark .text-gray-800 { color: #f3f4f6; }
html.dark .text-gray-700 { color: #e5e7eb; }
html.dark .text-gray-600 { color: #d1d5db; }
html.dark .text-gray-500 { color: #9ca3af; }
html.dark .text-gray-400 { color: #9ca3af; }
html.dark .text-black { color: #f9fafb; }
/* ---- Borders ---- */
html.dark .border-gray-100 { border-color: #374151; }
html.dark .border-gray-200 { border-color: #374151; }
html.dark .border-gray-300 { border-color: #4b5563; }
html.dark .border-gray-400 { border-color: #6b7280; }
html.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
html.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
html.dark .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
/* ---- Hover states ---- */
html.dark .hover\:bg-gray-50:hover { background-color: #374151; }
html.dark .hover\:bg-gray-100:hover { background-color: #4b5563; }
html.dark .hover\:text-gray-900:hover { color: #f9fafb; }
html.dark .hover\:text-gray-700:hover { color: #e5e7eb; }
/* ---- Shadows (softened for dark mode) ---- */
html.dark .shadow,
html.dark .shadow-md,
html.dark .shadow-sm,
html.dark .shadow-lg {
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.6), 0 1px 2px 0 rgba(0,0,0,0.4);
}
/* ---- Alert / info-banner backgrounds ---- */
html.dark .bg-blue-50 { background-color: #1e3a5f; }
html.dark .bg-green-50 { background-color: #052e16; }
html.dark .bg-red-50 { background-color: #450a0a; }
html.dark .bg-yellow-50 { background-color: #451a03; }
html.dark .bg-indigo-50 { background-color: #1e1b4b; }
html.dark .bg-orange-50 { background-color: #431407; }
/* ---- Badge / pill backgrounds ---- */
html.dark .bg-blue-100 { background-color: #1e3a5f; }
html.dark .bg-green-100 { background-color: #052e16; }
html.dark .bg-red-100 { background-color: #450a0a; }
html.dark .bg-yellow-100 { background-color: #451a03; }
html.dark .bg-indigo-100 { background-color: #431407; }
html.dark .bg-orange-100 { background-color: #431407; }
html.dark .bg-purple-100 { background-color: #2e1065; }
/* ---- Status / badge text colours ---- */
html.dark .text-blue-700 { color: #93c5fd; }
html.dark .text-blue-800 { color: #bfdbfe; }
html.dark .text-green-700 { color: #86efac; }
html.dark .text-green-800 { color: #bbf7d0; }
html.dark .text-red-700 { color: #fca5a5; }
html.dark .text-red-800 { color: #fecaca; }
html.dark .text-yellow-700 { color: #fcd34d; }
html.dark .text-yellow-800 { color: #fde68a; }
html.dark .text-indigo-700 { color: #a5b4fc; }
html.dark .text-indigo-800 { color: #c7d2fe; }
html.dark .text-orange-700 { color: #fdba74; }
html.dark .text-orange-800 { color: #fed7aa; }
html.dark .text-purple-700 { color: #d8b4fe; }
html.dark .text-purple-800 { color: #e9d5ff; }
/* ---- Dropdown / popup menus ---- */
html.dark .bg-white.rounded-md.shadow-lg { background-color: #1f2937; }
html.dark .ring-black { --tw-ring-color: rgba(0,0,0,0.5); }
/* ---- Form inputs / selects / textareas ---- */
html.dark input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
html.dark select,
html.dark textarea {
background-color: #374151;
border-color: #4b5563;
color: #e5e7eb;
}
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #9ca3af;
}
html.dark input:focus:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
html.dark select:focus,
html.dark textarea:focus {
border-color: #60a5fa;
outline-color: #60a5fa;
}
/* ---- Table rows ---- */
html.dark thead,
html.dark .bg-gray-50 thead { background-color: #1f2937; }
html.dark thead th { color: #9ca3af; }
html.dark tbody tr:hover { background-color: #374151; }
/* ---- Code / pre ---- */
html.dark pre,
html.dark code { background-color: #111827; color: #d1d5db; }
/* ---- Dark-mode toggle button icon colour ---- */
html.dark #darkModeToggle { color: #fbbf24; }
html.dark #darkModeToggle:hover { background-color: #374151; }
/* ---- Dark-mode skip-link ---- */
html.dark .skip-link { background-color: #2563eb; }
html.dark .skip-link:focus { outline-color: #60a5fa; }
/* ---- Dark-mode focus-visible indicators ---- */
html.dark a:focus-visible,
html.dark button:focus-visible,
html.dark input:focus-visible,
html.dark select:focus-visible,
html.dark textarea:focus-visible,
html.dark [tabindex]:focus-visible {
outline-color: #60a5fa;
}
/* ---- Scrollbar (WebKit browsers) ---- */
html.dark ::-webkit-scrollbar { width: 8px; height: 8px; }
html.dark ::-webkit-scrollbar-track { background: #1f2937; }
html.dark ::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
html.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }
/* ---- Settings page: sidebar active state (dark) ---- */
html.dark .bg-blue-50 { background-color: #1e3a5f; }
/* =============================================================
DOC-TOGGLE cross-browser toggle switch
Implemented with custom CSS pseudo-elements so the appearance
is consistent across all browsers regardless of Tailwind version.
Usage:
<label class="doc-toggle">
<input type="checkbox" class="sr-only" onchange="...">
<span class="doc-toggle-track" aria-hidden="true"></span>
<span class="ml-3 ...">Label text</span>
</label>
============================================================= */
.doc-toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
}
.doc-toggle-track {
position: relative;
display: inline-block;
width: 44px;
min-width: 44px;
height: 24px;
background-color: #e5e7eb; /* gray-200 */
border-radius: 9999px;
transition: background-color 0.2s ease-in-out;
flex-shrink: 0;
}
.doc-toggle-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background-color: #ffffff;
border: 1px solid #d1d5db; /* gray-300 */
border-radius: 9999px;
transition: transform 0.2s ease-in-out, border-color 0.2s ease-in-out;
}
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
background-color: #4f46e5; /* indigo-600 */
}
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
transform: translateX(20px);
border-color: #ffffff;
}
.doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #6366f1; /* ring-2 ring-indigo-500 with offset */
}
/* Dark mode overrides */
html.dark .doc-toggle-track {
background-color: #374151; /* gray-700 */
}
html.dark .doc-toggle-track::after {
background-color: #ffffff;
border-color: #4b5563; /* gray-600 */
}
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
background-color: #4f46e5; /* indigo-600 */
}
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
border-color: #ffffff;
}
html.dark .doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
box-shadow: 0 0 0 2px #111827, 0 0 0 4px #6366f1; /* dark background offset */
}
+1017
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "docuelevate-frontend",
"version": "1.0.0",
"description": "Frontend asset compilation for DocuElevate",
"scripts": {
"build": "tailwindcss -i input.css -o static/styles.css --minify",
"watch": "tailwindcss -i input.css -o static/styles.css --watch"
},
"devDependencies": {
"tailwindcss": "^3.4.0"
}
}
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./templates/**/*.html',
'./static/js/**/*.js',
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}
-2
View File
@@ -21,8 +21,6 @@
<!-- Alpine.js moved to head for earlier loading -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
{% block head_css %}
<!-- Tailwind CSS and other CSS -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _("billing.success_page_title") }}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Forgot Password</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Forgot Username</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _("app.name") }} - {{ _("auth.login_title") }}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Reset Password</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<title>Shared Document DocuElevate</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Tailwind CSS -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
<!-- Tailwind CSS v3 (compiled) -->
<link rel="stylesheet" href="/static/styles.css" />
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Create Account</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _("auth.verify_email_page_title") }}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+3 -3
View File
@@ -430,8 +430,8 @@ class TestLoginFunction:
# Verify TemplateResponse was called with correct context
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
assert call_args[0][0] == "login.html"
context = call_args[0][1]
assert call_args[0][1] == "login.html"
context = call_args.kwargs["context"]
assert context["error"] == "Test error"
assert context["message"] == "Test message"
@@ -450,7 +450,7 @@ class TestLoginFunction:
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
context = call_args.kwargs["context"]
assert context["error"] is None
assert context["message"] is None
+1 -1
View File
@@ -281,7 +281,7 @@ class TestLoginEndpoint:
# Verify template was rendered with OAuth enabled
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
context = call_args.kwargs["context"]
assert context["show_oauth"] is True
assert context["oauth_provider_name"] == "Test SSO"
+2 -2
View File
@@ -520,14 +520,14 @@ class TestURLUploadAdditionalCoverage:
assert exc_info.value.status_code == 400
def test_is_private_ip_unresolvable_hostname(self):
"""Cover DNS resolution failure branch (lines 67-72)."""
"""Cover DNS resolution failure branch blocking unresolvable domains."""
import socket as _socket
from app.utils.network import is_private_ip
with patch("socket.getaddrinfo", side_effect=_socket.gaierror("nope")):
result = is_private_ip("nonexistent.invalid.hostname.test")
assert result is False
assert result is True # Fail securely by returning True
def test_is_private_ip_hostname_resolves_to_private(self):
"""Cover branch where hostname resolves to a private IP (line 64-65)."""
+3 -2
View File
@@ -69,8 +69,9 @@ class TestViewsBase:
context = {"request": req}
template_response_with_version("template.html", context)
args, _ = mock_orig.call_args
assert args[1].get("csrf_token") == "my-csrf"
args, kwargs = mock_orig.call_args
context = kwargs.get("context", {})
assert context.get("csrf_token") == "my-csrf"
def test_kwargs_context_no_request(self):
"""Test kwargs context path when request is not in context."""
+4 -4
View File
@@ -55,8 +55,8 @@ class TestDarkModeTemplateInjection:
captured = {}
def fake_original(name, ctx, **kw):
captured.update(ctx)
def fake_original(request_obj, name, context=None, **kw):
captured.update(context or {})
with patch("app.views.base.original_template_response", side_effect=fake_original):
mock_request = MagicMock()
@@ -73,8 +73,8 @@ class TestDarkModeTemplateInjection:
captured = {}
def fake_original(name, ctx, **kw):
captured.update(ctx)
def fake_original(request_obj, name, context=None, **kw):
captured.update(context or {})
with patch("app.views.base.original_template_response", side_effect=fake_original):
mock_request = MagicMock()
+145
View File
@@ -0,0 +1,145 @@
"""Tests for frontend build configuration and Docker build consistency.
Validates that the frontend build toolchain (Tailwind CSS) is correctly
configured in package.json and that the Dockerfile installs all required
dependencies for the build step.
"""
import json
import re
from pathlib import Path
import pytest
# Resolve the project root from the test file location
PROJECT_ROOT = Path(__file__).resolve().parent.parent
FRONTEND_DIR = PROJECT_ROOT / "frontend"
DOCKERFILE_PATH = PROJECT_ROOT / "Dockerfile"
@pytest.mark.unit
class TestFrontendPackageJson:
"""Validate frontend/package.json structure and scripts."""
def test_package_json_exists(self) -> None:
"""package.json must exist in the frontend directory."""
pkg_path = FRONTEND_DIR / "package.json"
assert pkg_path.exists(), "frontend/package.json not found"
def test_package_json_is_valid_json(self) -> None:
"""package.json must be parseable JSON."""
pkg_path = FRONTEND_DIR / "package.json"
data = json.loads(pkg_path.read_text(encoding="utf-8"))
assert isinstance(data, dict), "package.json must be a JSON object"
def test_build_script_defined(self) -> None:
"""A 'build' script must be defined in package.json."""
pkg_path = FRONTEND_DIR / "package.json"
data = json.loads(pkg_path.read_text(encoding="utf-8"))
scripts = data.get("scripts", {})
assert "build" in scripts, "Missing 'build' script in package.json"
def test_build_script_uses_tailwindcss(self) -> None:
"""The build script must invoke the tailwindcss CLI."""
pkg_path = FRONTEND_DIR / "package.json"
data = json.loads(pkg_path.read_text(encoding="utf-8"))
build_cmd = data["scripts"]["build"]
assert "tailwindcss" in build_cmd, f"Build script does not reference tailwindcss: {build_cmd}"
def test_tailwindcss_listed_as_dependency(self) -> None:
"""tailwindcss must be listed in dependencies or devDependencies."""
pkg_path = FRONTEND_DIR / "package.json"
data = json.loads(pkg_path.read_text(encoding="utf-8"))
deps = data.get("dependencies", {})
dev_deps = data.get("devDependencies", {})
all_deps = {**deps, **dev_deps}
assert "tailwindcss" in all_deps, "tailwindcss is not listed in dependencies or devDependencies"
@pytest.mark.unit
class TestFrontendBuildAssets:
"""Validate that required frontend build source files exist."""
def test_input_css_exists(self) -> None:
"""The Tailwind CSS input file must exist."""
input_css = FRONTEND_DIR / "input.css"
assert input_css.exists(), "frontend/input.css not found"
def test_input_css_has_tailwind_directives(self) -> None:
"""input.css must include Tailwind CSS directives."""
input_css = FRONTEND_DIR / "input.css"
content = input_css.read_text(encoding="utf-8")
assert "@tailwind base" in content, "Missing @tailwind base directive"
assert "@tailwind components" in content, "Missing @tailwind components directive"
assert "@tailwind utilities" in content, "Missing @tailwind utilities directive"
def test_tailwind_config_exists(self) -> None:
"""tailwind.config.js must exist in the frontend directory."""
config_path = FRONTEND_DIR / "tailwind.config.js"
assert config_path.exists(), "frontend/tailwind.config.js not found"
def test_package_lock_exists(self) -> None:
"""package-lock.json must exist for reproducible installs."""
lock_path = FRONTEND_DIR / "package-lock.json"
assert lock_path.exists(), "frontend/package-lock.json not found"
@pytest.mark.unit
class TestDockerfileFrontendBuilder:
"""Validate the Dockerfile frontend-builder stage installs build dependencies."""
def test_dockerfile_exists(self) -> None:
"""Production Dockerfile must exist at the project root."""
assert DOCKERFILE_PATH.exists(), "Dockerfile not found at project root"
def test_dockerfile_has_frontend_builder_stage(self) -> None:
"""Dockerfile must define a frontend-builder stage."""
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
assert "AS frontend-builder" in content, "Dockerfile does not define a frontend-builder stage"
def test_dockerfile_npm_ci_does_not_omit_dev(self) -> None:
"""npm ci must NOT use --omit=dev in the frontend-builder stage.
The tailwindcss CLI is a devDependency required at build time.
Using --omit=dev would skip installing it, causing the build to
fail with 'tailwindcss: not found'.
"""
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
# Extract the frontend-builder stage content
# Look for the stage start and the next stage (or end of file)
stage_pattern = re.compile(
r"FROM\s+\S+\s+AS\s+frontend-builder\b(.*?)(?=FROM\s|\Z)",
re.DOTALL,
)
match = stage_pattern.search(content)
assert match is not None, "Could not find frontend-builder stage in Dockerfile"
stage_content = match.group(1)
assert "--omit=dev" not in stage_content, (
"Dockerfile frontend-builder stage uses 'npm ci --omit=dev' which "
"excludes tailwindcss (a devDependency) needed for the build step. "
"Use 'npm ci' instead to install all dependencies."
)
def test_dockerfile_runs_npm_build(self) -> None:
"""Dockerfile frontend-builder stage must run npm run build."""
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
stage_pattern = re.compile(
r"FROM\s+\S+\s+AS\s+frontend-builder\b(.*?)(?=FROM\s|\Z)",
re.DOTALL,
)
match = stage_pattern.search(content)
assert match is not None, "Could not find frontend-builder stage in Dockerfile"
stage_content = match.group(1)
assert "npm run build" in stage_content, "Dockerfile frontend-builder stage does not run 'npm run build'"
def test_dockerfile_copies_compiled_css(self) -> None:
"""Dockerfile must copy the compiled styles.css from the frontend-builder stage."""
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
assert "COPY --from=frontend-builder" in content, (
"Dockerfile does not copy assets from the frontend-builder stage"
)
assert "styles.css" in content, "Dockerfile does not reference the compiled styles.css"
+2 -2
View File
@@ -345,7 +345,7 @@ class TestLoginPageSocialProviders:
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
context = call_args.kwargs.get("context", {})
assert context["social_providers"] == mock_providers
@pytest.mark.asyncio
@@ -371,7 +371,7 @@ class TestLoginPageSocialProviders:
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
context = call_args.kwargs.get("context", {})
assert context["social_providers"] == {}
@@ -0,0 +1,54 @@
import os
from unittest.mock import MagicMock, patch
import pytest
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
@pytest.fixture
def mock_settings():
with patch("app.tasks.upload_to_nextcloud.settings") as mock:
mock.nextcloud_upload_url = "http://nextcloud.local/"
mock.nextcloud_username = "testuser"
mock.nextcloud_password = "testpassword"
mock.nextcloud_folder = "uploads"
mock.workdir = "/tmp/workdir"
mock.http_request_timeout = 30
yield mock
@pytest.fixture
def mock_requests():
with patch("app.tasks.upload_to_nextcloud.requests") as mock:
# Mock PROPFIND to always return false (file doesn't exist)
mock.request.return_value = MagicMock(text="<response></response>")
# Mock PUT to return success
put_response = MagicMock()
put_response.status_code = 201
mock.put.return_value = put_response
yield mock
def test_upload_to_nextcloud_url_construction(mock_settings, mock_requests):
file_path = "/tmp/workdir/test_file.txt"
# Create dummy file
os.makedirs("/tmp/workdir", exist_ok=True)
with open(file_path, "w") as f:
f.write("test content")
# Call the task directly
with patch("celery.app.task.Task.request", new_callable=MagicMock) as mock_req:
mock_req.id = "test-task-123"
result = upload_to_nextcloud(file_path)
assert result["status"] == "Completed"
assert result["nextcloud_path"] == "uploads/test_file.txt"
# Verify requests.put was called with the correct URL
mock_requests.put.assert_called_once()
args, kwargs = mock_requests.put.call_args
url = args[0]
assert url == "http://nextcloud.local/uploads/test_file.txt"
+12
View File
@@ -698,6 +698,18 @@ class TestURLUploadCoverageGaps:
assert result is False
mock_getaddrinfo.assert_called_once()
@patch("app.utils.network.socket.getaddrinfo")
def test_is_private_ip_unresolvable_hostname_fails_securely(self, mock_getaddrinfo):
"""Test that unresolvable hostnames fail securely by blocking access."""
import socket
from app.utils.network import is_private_ip
mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")
result = is_private_ip("unresolvable.example.internal")
assert result is True # Fails securely
@patch("socket.getaddrinfo")
def test_is_private_ip_hostname_resolves_multiple_ips_all_public(self, mock_getaddrinfo):
"""Test hostname with multiple public IPs returns False (covers 65->61 loop branch)"""