Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fd3e745cf | |||
| 084171395d | |||
| 958b195e79 | |||
| c5ef1ec50c | |||
| ef897f660d | |||
| 6cb9feacab | |||
| 76c0e91500 | |||
| 0c7ea6748d | |||
| 78077fa8c7 | |||
| 242846aa9c | |||
| 868613ac49 | |||
| 33a0e49acd | |||
| 14b3031e63 | |||
| 1d7df13c94 | |||
| ce4bca0186 | |||
| 4b07e996ad | |||
| 720c9c11b0 | |||
| 425472c839 | |||
| 55afa4981b | |||
| 63f7b62fc0 | |||
| 48a303d498 | |||
| 8f1fe79411 | |||
| 3e1b352930 | |||
| 46772fc746 | |||
| 25d32a9006 | |||
| 8c6a02885d | |||
| 899cc56638 | |||
| 9822ba583d | |||
| 2288b89cd7 | |||
| 3d0bdf7836 | |||
| 41844c4b60 | |||
| 94aa2ebe57 | |||
| be97a757a3 | |||
| a5df6dc9cb | |||
| d4cc44a72f | |||
| 61dee5ba52 | |||
| 5f94e64734 | |||
| 9be03d8690 | |||
| 5c5b3ac054 | |||
| 9458055661 | |||
| bb116dcdd3 | |||
| 725bf98352 | |||
| 962495ba8c | |||
| 3843bce596 | |||
| 2df92ce469 | |||
| b25aaf879f | |||
| 4120a502df | |||
| 9c98a8438a | |||
| 49b816c878 |
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
2026-03-22T14:46:09Z
|
||||
2026-03-23T14:11:22Z
|
||||
|
||||
+127
@@ -10,6 +10,133 @@ 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
|
||||
|
||||
- **ui**: Add missing opening script tag in base.html Sentry block
|
||||
([`425472c`](https://github.com/christianlouis/DocuElevate/commit/425472c839b3564c20a29b6e983fa6b9e7d6cf9c))
|
||||
|
||||
|
||||
## v0.171.2 (2026-03-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ui**: Fix greyed-out toggle switches on admin connections page
|
||||
([`46772fc`](https://github.com/christianlouis/DocuElevate/commit/46772fc7461f9f8333b399b301ec969f5caa4c1e))
|
||||
|
||||
### Chores
|
||||
|
||||
- Upgrade Sentry Browser SDK CDN bundle from v9.x.x to v10.x.x
|
||||
([`3d0bdf7`](https://github.com/christianlouis/DocuElevate/commit/3d0bdf783649019d631fa0b1156db66e5e3269b4))
|
||||
|
||||
### Documentation
|
||||
|
||||
- **changelog**: Update changelog [skip ci]
|
||||
([`25d32a9`](https://github.com/christianlouis/DocuElevate/commit/25d32a9006161b433dc6bbac3810ab560e5e5847))
|
||||
|
||||
- **changelog**: Update changelog [skip ci]
|
||||
([`9822ba5`](https://github.com/christianlouis/DocuElevate/commit/9822ba583d076671692699cd9856d8fbc7d0218d))
|
||||
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Chores
|
||||
|
||||
- Upgrade Sentry Browser SDK CDN bundle from v9.x.x to v10.x.x
|
||||
([`3d0bdf7`](https://github.com/christianlouis/DocuElevate/commit/3d0bdf783649019d631fa0b1156db66e5e3269b4))
|
||||
|
||||
### Documentation
|
||||
|
||||
- **changelog**: Update changelog [skip ci]
|
||||
([`9822ba5`](https://github.com/christianlouis/DocuElevate/commit/9822ba583d076671692699cd9856d8fbc7d0218d))
|
||||
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Chores
|
||||
|
||||
- Upgrade Sentry Browser SDK CDN bundle from v9.x.x to v10.x.x
|
||||
([`3d0bdf7`](https://github.com/christianlouis/DocuElevate/commit/3d0bdf783649019d631fa0b1156db66e5e3269b4))
|
||||
|
||||
|
||||
## v0.171.1 (2026-03-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **admin**: Fix greyed-out QR login toggle on admin connections page
|
||||
([`d4cc44a`](https://github.com/christianlouis/DocuElevate/commit/d4cc44a72f7821360ffce1c9743efb85dbc65f22))
|
||||
|
||||
|
||||
## v0.171.0 (2026-03-22)
|
||||
|
||||
### Features
|
||||
|
||||
- **ui**: Show file owner, add claim ownership on file summary, detail, and annotations pages
|
||||
([`9458055`](https://github.com/christianlouis/DocuElevate/commit/9458055661e5458256b51cfe1965b0607d6a478e))
|
||||
|
||||
|
||||
## v0.170.0 (2026-03-22)
|
||||
|
||||
### Features
|
||||
|
||||
- **ui**: Integrate EmbedPDF viewer with annotations panel for bidirectional sync
|
||||
([`9c98a84`](https://github.com/christianlouis/DocuElevate/commit/9c98a8438ab81c70cc497191449378b914775731))
|
||||
|
||||
|
||||
## v0.169.1 (2026-03-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api**: Add PUT /api/settings/{key} endpoint and shared credentials for Google/Microsoft social
|
||||
login
|
||||
([`7d6128d`](https://github.com/christianlouis/DocuElevate/commit/7d6128d78f7782d4a687b51220747714ab59df6d))
|
||||
|
||||
|
||||
## v0.169.0 (2026-03-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+17
-2
@@ -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
|
||||
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
DocuElevate Build Information
|
||||
==============================
|
||||
Version: 0.169.0
|
||||
Build Date: 2026-03-22T14:46:09Z
|
||||
Git Commit: 4a35aabdaa92dfa55d1f3a332691df81e56f025a
|
||||
Git Short SHA: 4a35aab
|
||||
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-22T15:45:45+01:00
|
||||
Build Timestamp: 2026-03-22T14:46:09Z
|
||||
Commit Date: 2026-03-23T15:10:59+01:00
|
||||
Build Timestamp: 2026-03-23T14:11:22Z
|
||||
==============================
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,6 +31,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_login
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.middleware.audit_log import get_client_ip
|
||||
from app.utils.session_manager import (
|
||||
@@ -151,6 +152,11 @@ async def create_challenge(
|
||||
displayed to the user. The mobile app scans this QR code and
|
||||
calls the ``/claim`` endpoint.
|
||||
"""
|
||||
if not settings.qr_login_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
|
||||
)
|
||||
ip = get_client_ip(request)
|
||||
challenge = create_qr_challenge(db, owner_id, ip_address=ip)
|
||||
|
||||
@@ -187,6 +193,11 @@ async def poll_challenge_status(
|
||||
The web UI calls this endpoint every few seconds to check if the
|
||||
mobile app has scanned the QR code and claimed the challenge.
|
||||
"""
|
||||
if not settings.qr_login_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
|
||||
)
|
||||
result = get_challenge_status(db, challenge_id, owner_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Challenge not found")
|
||||
@@ -206,6 +217,11 @@ async def claim_challenge(
|
||||
serves as proof that the user authorized this login from their web
|
||||
session.
|
||||
"""
|
||||
if not settings.qr_login_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
|
||||
)
|
||||
ip = get_client_ip(request)
|
||||
result = claim_qr_challenge(db, body.challenge_token, device_name=body.device_name, ip_address=ip)
|
||||
|
||||
|
||||
+221
-149
@@ -45,78 +45,27 @@ OAUTH_PROVIDER_NAME = "Single Sign-On"
|
||||
# Social login providers that are enabled and registered
|
||||
SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {}
|
||||
|
||||
if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_secret:
|
||||
oauth.register(
|
||||
name="authentik",
|
||||
client_id=settings.authentik_client_id,
|
||||
client_secret=settings.authentik_client_secret,
|
||||
server_metadata_url=settings.authentik_config_url,
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
OAUTH_CONFIGURED = True
|
||||
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
|
||||
|
||||
# --- Social Login Providers ---------------------------------------------------
|
||||
if AUTH_ENABLED and settings.social_auth_google_enabled:
|
||||
# Determine which credentials to use for Google social login
|
||||
_google_client_id = settings.social_auth_google_client_id
|
||||
_google_client_secret = settings.social_auth_google_client_secret
|
||||
if settings.social_auth_google_use_global_credentials and not (_google_client_id and _google_client_secret):
|
||||
_google_client_id = settings.google_drive_client_id
|
||||
_google_client_secret = settings.google_drive_client_secret
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for dynamic (re-)registration of OAuth providers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if _google_client_id and _google_client_secret:
|
||||
oauth.register(
|
||||
name="google",
|
||||
client_id=_google_client_id,
|
||||
client_secret=_google_client_secret,
|
||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
|
||||
logger.info("Social login provider registered: Google")
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
|
||||
|
||||
if AUTH_ENABLED and settings.social_auth_microsoft_enabled:
|
||||
# Determine which credentials to use for Microsoft social login
|
||||
_microsoft_client_id = settings.social_auth_microsoft_client_id
|
||||
_microsoft_client_secret = settings.social_auth_microsoft_client_secret
|
||||
if settings.social_auth_microsoft_use_global_credentials and not (
|
||||
_microsoft_client_id and _microsoft_client_secret
|
||||
):
|
||||
_microsoft_client_id = settings.onedrive_client_id
|
||||
_microsoft_client_secret = settings.onedrive_client_secret
|
||||
def _register_oauth_client(name: str, **kwargs: object) -> None:
|
||||
"""Register (or re-register) an authlib OAuth client, clearing any cached instance.
|
||||
|
||||
if _microsoft_client_id and _microsoft_client_secret:
|
||||
tenant = settings.social_auth_microsoft_tenant or "common"
|
||||
oauth.register(
|
||||
name="microsoft",
|
||||
client_id=_microsoft_client_id,
|
||||
client_secret=_microsoft_client_secret,
|
||||
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}
|
||||
logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant)
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
|
||||
authlib caches the constructed client object in ``oauth._clients`` after the
|
||||
first ``register()`` call. Subsequent ``register()`` calls overwrite the
|
||||
registry entry but the stale cached client is still returned by
|
||||
``create_client()`` / ``__getattr__``. Popping the name from ``_clients``
|
||||
before re-registering ensures the new credentials are picked up immediately.
|
||||
|
||||
if AUTH_ENABLED and settings.social_auth_apple_enabled:
|
||||
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
|
||||
oauth.register(
|
||||
name="apple",
|
||||
client_id=settings.social_auth_apple_client_id,
|
||||
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
|
||||
client_kwargs={
|
||||
"scope": "openid name email",
|
||||
"response_mode": "form_post",
|
||||
},
|
||||
)
|
||||
SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"}
|
||||
logger.info("Social login provider registered: Apple")
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
|
||||
Args:
|
||||
name: Provider name (e.g. ``"google"``, ``"github"``).
|
||||
**kwargs: Keyword arguments forwarded verbatim to ``oauth.register()``.
|
||||
"""
|
||||
oauth._clients.pop(name, None)
|
||||
oauth.register(name, **kwargs)
|
||||
|
||||
|
||||
def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
|
||||
@@ -145,92 +94,215 @@ def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
|
||||
return data
|
||||
|
||||
|
||||
if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
|
||||
# Determine which credentials to use for Dropbox social login
|
||||
_dropbox_client_id = settings.social_auth_dropbox_client_id
|
||||
_dropbox_client_secret = settings.social_auth_dropbox_client_secret
|
||||
if settings.social_auth_dropbox_use_global_credentials and not (_dropbox_client_id and _dropbox_client_secret):
|
||||
_dropbox_client_id = settings.dropbox_app_key
|
||||
_dropbox_client_secret = settings.dropbox_app_secret
|
||||
def _setup_social_providers() -> None:
|
||||
"""Register all configured OAuth / social-login providers from current settings.
|
||||
|
||||
if _dropbox_client_id and _dropbox_client_secret:
|
||||
oauth.register(
|
||||
name="dropbox",
|
||||
client_id=_dropbox_client_id,
|
||||
client_secret=_dropbox_client_secret,
|
||||
authorize_url="https://www.dropbox.com/oauth2/authorize",
|
||||
access_token_url="https://api.dropboxapi.com/oauth2/token",
|
||||
userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
|
||||
userinfo_compliance_fix=_dropbox_userinfo_compliance_fix,
|
||||
client_kwargs={
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"token_access_type": "offline",
|
||||
},
|
||||
)
|
||||
SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
|
||||
logger.info("Social login provider registered: Dropbox")
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
|
||||
This function is **idempotent**: it clears ``SOCIAL_PROVIDERS``,
|
||||
``OAUTH_CONFIGURED``, and ``OAUTH_PROVIDER_NAME`` before rebuilding them,
|
||||
and calls :func:`_register_oauth_client` (which also clears the authlib
|
||||
client cache) so that credential changes in the database are reflected
|
||||
without an application restart.
|
||||
|
||||
if AUTH_ENABLED and settings.social_auth_github_enabled:
|
||||
if settings.social_auth_github_client_id and settings.social_auth_github_client_secret:
|
||||
oauth.register(
|
||||
name="github",
|
||||
client_id=settings.social_auth_github_client_id,
|
||||
client_secret=settings.social_auth_github_client_secret,
|
||||
authorize_url="https://github.com/login/oauth/authorize",
|
||||
access_token_url="https://github.com/login/oauth/access_token",
|
||||
userinfo_endpoint="https://api.github.com/user",
|
||||
client_kwargs={"scope": "read:user user:email"},
|
||||
)
|
||||
SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"}
|
||||
logger.info("Social login provider registered: GitHub")
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured")
|
||||
Can safely be called multiple times, e.g. after a settings reload.
|
||||
"""
|
||||
global OAUTH_CONFIGURED, OAUTH_PROVIDER_NAME
|
||||
|
||||
if AUTH_ENABLED and settings.social_auth_keycloak_enabled:
|
||||
_kc_server = settings.social_auth_keycloak_server_url
|
||||
_kc_realm = settings.social_auth_keycloak_realm
|
||||
if (
|
||||
settings.social_auth_keycloak_client_id
|
||||
and settings.social_auth_keycloak_client_secret
|
||||
and _kc_server
|
||||
and _kc_realm
|
||||
):
|
||||
_kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}"
|
||||
oauth.register(
|
||||
name="keycloak",
|
||||
client_id=settings.social_auth_keycloak_client_id,
|
||||
client_secret=settings.social_auth_keycloak_client_secret,
|
||||
server_metadata_url=f"{_kc_base}/.well-known/openid-configuration",
|
||||
SOCIAL_PROVIDERS.clear()
|
||||
OAUTH_CONFIGURED = False
|
||||
OAUTH_PROVIDER_NAME = "Single Sign-On"
|
||||
|
||||
if not AUTH_ENABLED:
|
||||
return
|
||||
|
||||
# --- Authentik / OIDC ---
|
||||
if settings.authentik_client_id and settings.authentik_client_secret:
|
||||
_register_oauth_client(
|
||||
"authentik",
|
||||
client_id=settings.authentik_client_id,
|
||||
client_secret=settings.authentik_client_secret,
|
||||
server_metadata_url=settings.authentik_config_url,
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"}
|
||||
logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm)
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured")
|
||||
OAUTH_CONFIGURED = True
|
||||
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
|
||||
|
||||
if AUTH_ENABLED and settings.social_auth_generic_oauth2_enabled:
|
||||
if (
|
||||
settings.social_auth_generic_oauth2_client_id
|
||||
and settings.social_auth_generic_oauth2_client_secret
|
||||
and settings.social_auth_generic_oauth2_authorize_url
|
||||
and settings.social_auth_generic_oauth2_token_url
|
||||
):
|
||||
oauth.register(
|
||||
name="generic_oauth2",
|
||||
client_id=settings.social_auth_generic_oauth2_client_id,
|
||||
client_secret=settings.social_auth_generic_oauth2_client_secret,
|
||||
authorize_url=settings.social_auth_generic_oauth2_authorize_url,
|
||||
access_token_url=settings.social_auth_generic_oauth2_token_url,
|
||||
userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url,
|
||||
client_kwargs={"scope": settings.social_auth_generic_oauth2_scope},
|
||||
)
|
||||
_generic_name = settings.social_auth_generic_oauth2_name or "OAuth2"
|
||||
SOCIAL_PROVIDERS["generic_oauth2"] = {"name": _generic_name, "icon": "fas fa-sign-in-alt", "color": "indigo"}
|
||||
logger.info("Social login provider registered: Generic OAuth2 (%s)", _generic_name)
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
|
||||
# --- Social Login Providers ---
|
||||
|
||||
# Google
|
||||
if settings.social_auth_google_enabled:
|
||||
_google_client_id = settings.social_auth_google_client_id
|
||||
_google_client_secret = settings.social_auth_google_client_secret
|
||||
if settings.social_auth_google_use_global_credentials and not (_google_client_id and _google_client_secret):
|
||||
_google_client_id = settings.google_drive_client_id
|
||||
_google_client_secret = settings.google_drive_client_secret
|
||||
|
||||
if _google_client_id and _google_client_secret:
|
||||
_register_oauth_client(
|
||||
"google",
|
||||
client_id=_google_client_id,
|
||||
client_secret=_google_client_secret,
|
||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
|
||||
logger.info("Social login provider registered: Google")
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
|
||||
|
||||
# Microsoft
|
||||
if settings.social_auth_microsoft_enabled:
|
||||
_microsoft_client_id = settings.social_auth_microsoft_client_id
|
||||
_microsoft_client_secret = settings.social_auth_microsoft_client_secret
|
||||
if settings.social_auth_microsoft_use_global_credentials and not (
|
||||
_microsoft_client_id and _microsoft_client_secret
|
||||
):
|
||||
_microsoft_client_id = settings.onedrive_client_id
|
||||
_microsoft_client_secret = settings.onedrive_client_secret
|
||||
|
||||
if _microsoft_client_id and _microsoft_client_secret:
|
||||
tenant = settings.social_auth_microsoft_tenant or "common"
|
||||
_register_oauth_client(
|
||||
"microsoft",
|
||||
client_id=_microsoft_client_id,
|
||||
client_secret=_microsoft_client_secret,
|
||||
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}
|
||||
logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant)
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
|
||||
|
||||
# Apple
|
||||
if settings.social_auth_apple_enabled:
|
||||
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
|
||||
_register_oauth_client(
|
||||
"apple",
|
||||
client_id=settings.social_auth_apple_client_id,
|
||||
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
|
||||
client_kwargs={
|
||||
"scope": "openid name email",
|
||||
"response_mode": "form_post",
|
||||
},
|
||||
)
|
||||
SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"}
|
||||
logger.info("Social login provider registered: Apple")
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
|
||||
|
||||
# Dropbox
|
||||
if settings.social_auth_dropbox_enabled:
|
||||
_dropbox_client_id = settings.social_auth_dropbox_client_id
|
||||
_dropbox_client_secret = settings.social_auth_dropbox_client_secret
|
||||
if settings.social_auth_dropbox_use_global_credentials and not (_dropbox_client_id and _dropbox_client_secret):
|
||||
_dropbox_client_id = settings.dropbox_app_key
|
||||
_dropbox_client_secret = settings.dropbox_app_secret
|
||||
|
||||
if _dropbox_client_id and _dropbox_client_secret:
|
||||
_register_oauth_client(
|
||||
"dropbox",
|
||||
client_id=_dropbox_client_id,
|
||||
client_secret=_dropbox_client_secret,
|
||||
authorize_url="https://www.dropbox.com/oauth2/authorize",
|
||||
access_token_url="https://api.dropboxapi.com/oauth2/token",
|
||||
userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
|
||||
userinfo_compliance_fix=_dropbox_userinfo_compliance_fix,
|
||||
client_kwargs={
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"token_access_type": "offline",
|
||||
},
|
||||
)
|
||||
SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
|
||||
logger.info("Social login provider registered: Dropbox")
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
|
||||
|
||||
# GitHub
|
||||
if settings.social_auth_github_enabled:
|
||||
if settings.social_auth_github_client_id and settings.social_auth_github_client_secret:
|
||||
_register_oauth_client(
|
||||
"github",
|
||||
client_id=settings.social_auth_github_client_id,
|
||||
client_secret=settings.social_auth_github_client_secret,
|
||||
authorize_url="https://github.com/login/oauth/authorize",
|
||||
access_token_url="https://github.com/login/oauth/access_token",
|
||||
userinfo_endpoint="https://api.github.com/user",
|
||||
client_kwargs={"scope": "read:user user:email"},
|
||||
)
|
||||
SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"}
|
||||
logger.info("Social login provider registered: GitHub")
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured")
|
||||
|
||||
# Keycloak
|
||||
if settings.social_auth_keycloak_enabled:
|
||||
_kc_server = settings.social_auth_keycloak_server_url
|
||||
_kc_realm = settings.social_auth_keycloak_realm
|
||||
if (
|
||||
settings.social_auth_keycloak_client_id
|
||||
and settings.social_auth_keycloak_client_secret
|
||||
and _kc_server
|
||||
and _kc_realm
|
||||
):
|
||||
_kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}"
|
||||
_register_oauth_client(
|
||||
"keycloak",
|
||||
client_id=settings.social_auth_keycloak_client_id,
|
||||
client_secret=settings.social_auth_keycloak_client_secret,
|
||||
server_metadata_url=f"{_kc_base}/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"}
|
||||
logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm)
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured")
|
||||
|
||||
# Generic OAuth2
|
||||
if settings.social_auth_generic_oauth2_enabled:
|
||||
if (
|
||||
settings.social_auth_generic_oauth2_client_id
|
||||
and settings.social_auth_generic_oauth2_client_secret
|
||||
and settings.social_auth_generic_oauth2_authorize_url
|
||||
and settings.social_auth_generic_oauth2_token_url
|
||||
):
|
||||
_register_oauth_client(
|
||||
"generic_oauth2",
|
||||
client_id=settings.social_auth_generic_oauth2_client_id,
|
||||
client_secret=settings.social_auth_generic_oauth2_client_secret,
|
||||
authorize_url=settings.social_auth_generic_oauth2_authorize_url,
|
||||
access_token_url=settings.social_auth_generic_oauth2_token_url,
|
||||
userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url,
|
||||
client_kwargs={"scope": settings.social_auth_generic_oauth2_scope},
|
||||
)
|
||||
_generic_name = settings.social_auth_generic_oauth2_name or "OAuth2"
|
||||
SOCIAL_PROVIDERS["generic_oauth2"] = {
|
||||
"name": _generic_name,
|
||||
"icon": "fas fa-sign-in-alt",
|
||||
"color": "indigo",
|
||||
}
|
||||
logger.info("Social login provider registered: Generic OAuth2")
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
|
||||
|
||||
|
||||
def refresh_social_providers() -> None:
|
||||
"""Re-register all OAuth providers from the *current* settings object.
|
||||
|
||||
Call this after loading or reloading settings from the database so that
|
||||
providers configured (or updated) through the admin UI take effect
|
||||
immediately — **no application restart required**.
|
||||
|
||||
This function is safe to call multiple times and is idempotent.
|
||||
"""
|
||||
logger.info("Refreshing social login provider registrations from current settings")
|
||||
_setup_social_providers()
|
||||
|
||||
|
||||
# Perform the initial registration from environment / default settings at
|
||||
# import time. The lifespan hook and settings_sync will call
|
||||
# refresh_social_providers() again after DB settings are loaded so that
|
||||
# any providers configured only in the database are also active.
|
||||
_setup_social_providers()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -464,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,
|
||||
|
||||
@@ -244,6 +244,10 @@ class Settings(BaseSettings):
|
||||
"Useful for admin-configured non-standard durations."
|
||||
),
|
||||
)
|
||||
qr_login_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Enable QR code-based login for mobile device authentication (default: True).",
|
||||
)
|
||||
qr_login_challenge_ttl_seconds: int = Field(
|
||||
default=120,
|
||||
description="Time-to-live in seconds for QR login challenges (default: 2 minutes).",
|
||||
|
||||
+16
-5
@@ -189,6 +189,18 @@ async def lifespan(app: FastAPI):
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Re-register OAuth / social-login providers now that DB settings are
|
||||
# loaded. auth.py runs its initial registration at import time (before
|
||||
# the lifespan runs), so providers that are only configured in the
|
||||
# database would not be registered yet. Calling refresh here ensures
|
||||
# they are active immediately on startup without any manual restart.
|
||||
try:
|
||||
from app.auth import refresh_social_providers
|
||||
|
||||
refresh_social_providers()
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not refresh social login providers on startup: {e}")
|
||||
|
||||
# Initialize Sentry after DB settings are loaded so that values configured
|
||||
# via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars.
|
||||
init_sentry()
|
||||
@@ -412,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,
|
||||
)
|
||||
|
||||
@@ -440,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
@@ -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
@@ -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
|
||||
|
||||
@@ -206,6 +206,14 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"qr_login_enabled": {
|
||||
"category": "Authentication",
|
||||
"description": "Enable QR code-based login for mobile device authentication.",
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"qr_login_challenge_ttl_seconds": {
|
||||
"category": "Authentication",
|
||||
"description": "Time-to-live in seconds for QR login challenges (default 120).",
|
||||
|
||||
@@ -71,6 +71,16 @@ def notify_settings_updated() -> None:
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not reload in-process settings: {exc}")
|
||||
|
||||
# Re-register OAuth / social-login providers so that any provider whose
|
||||
# credentials were just saved (or updated) in the database is active
|
||||
# immediately on the login page — no restart required.
|
||||
try:
|
||||
from app.auth import refresh_social_providers
|
||||
|
||||
refresh_social_providers()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not refresh social login providers after settings update: {exc}")
|
||||
|
||||
# Re-check OCR language availability in the background whenever settings
|
||||
# are updated. This ensures that if a user changes tesseract_language or
|
||||
# easyocr_languages via the UI, the new language data is downloaded without
|
||||
|
||||
+29
-5
@@ -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)
|
||||
|
||||
|
||||
|
||||
+44
-12
@@ -19,6 +19,43 @@ router = APIRouter()
|
||||
_FILE_NOT_FOUND = "File not found"
|
||||
|
||||
|
||||
def _resolve_owner_context(request: Request, file_record, db: Session) -> dict:
|
||||
"""Return owner display info and the current user's effective role.
|
||||
|
||||
Returns a dict with:
|
||||
- ``current_user_role``: one of "owner" / "editor" / "viewer" / None
|
||||
- ``owner_display``: human-readable owner string (display_name or user_id)
|
||||
- ``multi_user_enabled``: whether multi-user mode is active
|
||||
"""
|
||||
from app.config import settings
|
||||
from app.models import UserProfile
|
||||
from app.utils.user_scope import get_current_owner_id, get_file_role
|
||||
|
||||
multi_user_enabled = settings.multi_user_enabled
|
||||
|
||||
current_owner_id = get_current_owner_id(request)
|
||||
user_session = request.session.get("user")
|
||||
is_admin = isinstance(user_session, dict) and bool(user_session.get("is_admin"))
|
||||
|
||||
if is_admin:
|
||||
current_user_role: str | None = "owner"
|
||||
else:
|
||||
current_user_role = get_file_role(file_record, current_owner_id, db)
|
||||
|
||||
# Build a human-readable owner label
|
||||
if file_record.owner_id:
|
||||
profile = db.query(UserProfile).filter(UserProfile.user_id == file_record.owner_id).first()
|
||||
owner_display: str | None = profile.display_name if profile and profile.display_name else file_record.owner_id
|
||||
else:
|
||||
owner_display = None # No owner (unowned)
|
||||
|
||||
return {
|
||||
"current_user_role": current_user_role,
|
||||
"owner_display": owner_display,
|
||||
"multi_user_enabled": multi_user_enabled,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
@require_login
|
||||
def files_page(
|
||||
@@ -268,6 +305,7 @@ def file_summary_page(request: Request, file_id: int, db: Session = Depends(get_
|
||||
step_summary = None
|
||||
|
||||
pipeline_info = _resolve_pipeline(db, file_record)
|
||||
owner_ctx = _resolve_owner_context(request, file_record, db)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"file_summary.html",
|
||||
@@ -279,6 +317,7 @@ def file_summary_page(request: Request, file_id: int, db: Session = Depends(get_
|
||||
"processed_file_exists": processed_file_exists,
|
||||
"step_summary": step_summary,
|
||||
"pipeline_info": pipeline_info,
|
||||
**owner_ctx,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -353,6 +392,7 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)
|
||||
|
||||
# Resolve the pipeline assigned to this file (explicit or system default)
|
||||
pipeline_info = _resolve_pipeline(db, file_record)
|
||||
owner_ctx = _resolve_owner_context(request, file_record, db)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"file_view.html",
|
||||
@@ -364,6 +404,7 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)
|
||||
"processed_file_exists": processed_file_exists,
|
||||
"step_summary": step_summary,
|
||||
"pipeline_info": pipeline_info,
|
||||
**owner_ctx,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -497,17 +538,8 @@ def file_annotations_page(request: Request, file_id: int, db: Session = Depends(
|
||||
mime = file_record.mime_type or ""
|
||||
is_pdf = mime == "application/pdf" or (file_record.original_filename or "").lower().endswith(".pdf")
|
||||
|
||||
# Determine the current user's role on this file
|
||||
from app.utils.user_scope import get_current_owner_id, get_file_role
|
||||
|
||||
current_owner_id = get_current_owner_id(request)
|
||||
user_session = request.session.get("user")
|
||||
is_admin = isinstance(user_session, dict) and bool(user_session.get("is_admin"))
|
||||
if is_admin:
|
||||
current_user_role: str | None = "owner"
|
||||
else:
|
||||
current_user_role = get_file_role(file_record, current_owner_id, db)
|
||||
# None means no access — the template will not show owner-only UI
|
||||
# Determine the current user's role on this file (and owner display info)
|
||||
owner_ctx = _resolve_owner_context(request, file_record, db)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"file_annotations.html",
|
||||
@@ -517,7 +549,7 @@ def file_annotations_page(request: Request, file_id: int, db: Session = Depends(
|
||||
"original_file_exists": original_file_exists,
|
||||
"processed_file_exists": processed_file_exists,
|
||||
"is_pdf": is_pdf,
|
||||
"current_user_role": current_user_role,
|
||||
**owner_ctx,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
+58
-13
@@ -206,8 +206,6 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
||||
SSO settings, and service integrations through a wizard-like interface.
|
||||
"""
|
||||
try:
|
||||
from app.auth import OAUTH_CONFIGURED, SOCIAL_PROVIDERS
|
||||
|
||||
db_settings = get_all_settings_from_db(db)
|
||||
|
||||
def _get_effective(key: str):
|
||||
@@ -227,13 +225,14 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
||||
services = []
|
||||
|
||||
# --- SSO (Authentik / OIDC) ---
|
||||
_oidc_linked = bool(_get_effective("authentik_client_id") and _get_effective("authentik_client_secret"))
|
||||
services.append(
|
||||
{
|
||||
"key": "oidc",
|
||||
"name": settings.oauth_provider_name or "Single Sign-On",
|
||||
"name": _get_effective("oauth_provider_name") or "Single Sign-On",
|
||||
"icon": "fas fa-lock",
|
||||
"type": "SSO",
|
||||
"linked": OAUTH_CONFIGURED,
|
||||
"linked": _oidc_linked,
|
||||
"description": "OpenID Connect SSO provider",
|
||||
"settings_keys": [
|
||||
"authentik_client_id",
|
||||
@@ -245,13 +244,23 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
# --- Google ---
|
||||
_google_id = _get_effective("social_auth_google_client_id")
|
||||
_google_secret = _get_effective("social_auth_google_client_secret")
|
||||
if _is_truthy(_get_effective("social_auth_google_use_global_credentials")) and not (
|
||||
_google_id and _google_secret
|
||||
):
|
||||
_google_id = _google_id or _get_effective("google_drive_client_id")
|
||||
_google_secret = _google_secret or _get_effective("google_drive_client_secret")
|
||||
_google_linked = bool(
|
||||
_is_truthy(_get_effective("social_auth_google_enabled")) and _google_id and _google_secret
|
||||
)
|
||||
services.append(
|
||||
{
|
||||
"key": "google",
|
||||
"name": "Google",
|
||||
"icon": "fab fa-google",
|
||||
"type": "Sign-in authentication",
|
||||
"linked": "google" in SOCIAL_PROVIDERS,
|
||||
"linked": _google_linked,
|
||||
"description": "Sign-in authentication",
|
||||
"settings_keys": [
|
||||
"social_auth_google_enabled",
|
||||
@@ -263,13 +272,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
# --- GitHub ---
|
||||
_github_linked = bool(
|
||||
_is_truthy(_get_effective("social_auth_github_enabled"))
|
||||
and _get_effective("social_auth_github_client_id")
|
||||
and _get_effective("social_auth_github_client_secret")
|
||||
)
|
||||
services.append(
|
||||
{
|
||||
"key": "github",
|
||||
"name": "GitHub",
|
||||
"icon": "fab fa-github",
|
||||
"type": "Sign-in authentication",
|
||||
"linked": "github" in SOCIAL_PROVIDERS,
|
||||
"linked": _github_linked,
|
||||
"description": "Sign-in authentication",
|
||||
"settings_keys": [
|
||||
"social_auth_github_enabled",
|
||||
@@ -280,13 +294,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
# --- Microsoft ---
|
||||
_ms_id = _get_effective("social_auth_microsoft_client_id")
|
||||
_ms_secret = _get_effective("social_auth_microsoft_client_secret")
|
||||
if _is_truthy(_get_effective("social_auth_microsoft_use_global_credentials")) and not (_ms_id and _ms_secret):
|
||||
_ms_id = _ms_id or _get_effective("onedrive_client_id")
|
||||
_ms_secret = _ms_secret or _get_effective("onedrive_client_secret")
|
||||
_microsoft_linked = bool(_is_truthy(_get_effective("social_auth_microsoft_enabled")) and _ms_id and _ms_secret)
|
||||
services.append(
|
||||
{
|
||||
"key": "microsoft",
|
||||
"name": "Microsoft",
|
||||
"icon": "fab fa-microsoft",
|
||||
"type": "Sign-in authentication",
|
||||
"linked": "microsoft" in SOCIAL_PROVIDERS,
|
||||
"linked": _microsoft_linked,
|
||||
"description": "Sign-in authentication",
|
||||
"settings_keys": [
|
||||
"social_auth_microsoft_enabled",
|
||||
@@ -299,13 +319,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
# --- Apple ---
|
||||
_apple_linked = bool(
|
||||
_is_truthy(_get_effective("social_auth_apple_enabled"))
|
||||
and _get_effective("social_auth_apple_client_id")
|
||||
and _get_effective("social_auth_apple_team_id")
|
||||
)
|
||||
services.append(
|
||||
{
|
||||
"key": "apple",
|
||||
"name": "Apple",
|
||||
"icon": "fab fa-apple",
|
||||
"type": "Sign-in authentication",
|
||||
"linked": "apple" in SOCIAL_PROVIDERS,
|
||||
"linked": _apple_linked,
|
||||
"description": "Sign-in authentication",
|
||||
"settings_keys": [
|
||||
"social_auth_apple_enabled",
|
||||
@@ -318,13 +343,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
# --- Dropbox ---
|
||||
_dbx_id = _get_effective("social_auth_dropbox_client_id")
|
||||
_dbx_secret = _get_effective("social_auth_dropbox_client_secret")
|
||||
if _is_truthy(_get_effective("social_auth_dropbox_use_global_credentials")) and not (_dbx_id and _dbx_secret):
|
||||
_dbx_id = _dbx_id or _get_effective("dropbox_app_key")
|
||||
_dbx_secret = _dbx_secret or _get_effective("dropbox_app_secret")
|
||||
_dropbox_linked = bool(_is_truthy(_get_effective("social_auth_dropbox_enabled")) and _dbx_id and _dbx_secret)
|
||||
services.append(
|
||||
{
|
||||
"key": "dropbox",
|
||||
"name": "Dropbox",
|
||||
"icon": "fab fa-dropbox",
|
||||
"type": "Sign-in authentication",
|
||||
"linked": "dropbox" in SOCIAL_PROVIDERS,
|
||||
"linked": _dropbox_linked,
|
||||
"description": "Sign-in authentication",
|
||||
"settings_keys": [
|
||||
"social_auth_dropbox_enabled",
|
||||
@@ -336,13 +367,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
# --- Keycloak ---
|
||||
_keycloak_linked = bool(
|
||||
_is_truthy(_get_effective("social_auth_keycloak_enabled"))
|
||||
and _get_effective("social_auth_keycloak_client_id")
|
||||
and _get_effective("social_auth_keycloak_client_secret")
|
||||
and _get_effective("social_auth_keycloak_server_url")
|
||||
and _get_effective("social_auth_keycloak_realm")
|
||||
)
|
||||
services.append(
|
||||
{
|
||||
"key": "keycloak",
|
||||
"name": "Keycloak",
|
||||
"icon": "fas fa-key",
|
||||
"type": "SSO",
|
||||
"linked": "keycloak" in SOCIAL_PROVIDERS,
|
||||
"linked": _keycloak_linked,
|
||||
"description": "SSO",
|
||||
"settings_keys": [
|
||||
"social_auth_keycloak_enabled",
|
||||
@@ -355,13 +393,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
# --- Generic OAuth2 ---
|
||||
_generic_oauth2_linked = bool(
|
||||
_is_truthy(_get_effective("social_auth_generic_oauth2_enabled"))
|
||||
and _get_effective("social_auth_generic_oauth2_client_id")
|
||||
and _get_effective("social_auth_generic_oauth2_client_secret")
|
||||
and _get_effective("social_auth_generic_oauth2_authorize_url")
|
||||
and _get_effective("social_auth_generic_oauth2_token_url")
|
||||
)
|
||||
services.append(
|
||||
{
|
||||
"key": "generic_oauth2",
|
||||
"name": "Generic OAuth2",
|
||||
"icon": "fas fa-sign-in-alt",
|
||||
"type": "SSO",
|
||||
"linked": "generic_oauth2" in SOCIAL_PROVIDERS,
|
||||
"linked": _generic_oauth2_linked,
|
||||
"description": "SSO",
|
||||
"settings_keys": [
|
||||
"social_auth_generic_oauth2_enabled",
|
||||
@@ -464,7 +509,7 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
||||
|
||||
# Feature toggles
|
||||
sso_auto_login = _is_truthy(_get_effective("sso_auto_login"))
|
||||
qr_login_enabled = _is_truthy(_get_effective("qr_login_challenge_ttl_seconds"))
|
||||
qr_login_enabled = _is_truthy(_get_effective("qr_login_enabled"))
|
||||
frontend_url_configured = bool(_get_effective("public_base_url"))
|
||||
|
||||
return templates.TemplateResponse(
|
||||
@@ -474,7 +519,7 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
||||
"services": services,
|
||||
"service_settings": service_settings,
|
||||
"sso_auto_login": sso_auto_login,
|
||||
"oauth_configured": OAUTH_CONFIGURED,
|
||||
"oauth_configured": _oidc_linked,
|
||||
"qr_login_enabled": qr_login_enabled,
|
||||
"frontend_url_configured": frontend_url_configured,
|
||||
"app_version": settings.version,
|
||||
|
||||
+2
-1
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:;
|
||||
|
||||
+1
-1
@@ -181,7 +181,7 @@ Simply leave `SENTRY_DSN` unset (or set it to an empty string). Neither the Pyt
|
||||
## SDK Version
|
||||
|
||||
- **Server:** DocuElevate uses [`sentry-sdk`](https://pypi.org/project/sentry-sdk/) `>=2.20.0,<3.0.0` with the `fastapi`, `celery`, and `sqlalchemy` extras.
|
||||
- **Browser:** The `bundle.tracing.replay.min.js` bundle is loaded from the official Sentry CDN (`browser.sentry-cdn.com`). The version pin is in `frontend/templates/base.html`.
|
||||
- **Browser:** The `bundle.tracing.replay.min.js` bundle from the Sentry Browser SDK **v10** is loaded from the official Sentry CDN (`browser.sentry-cdn.com`). The version pin is in `frontend/templates/base.html`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
Generated
+1017
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@
|
||||
_i18n = i18n || {};
|
||||
_loadAnnotations();
|
||||
|
||||
// Expose reload function so the EmbedPDF viewer init script can refresh the
|
||||
// list after auto-saving an annotation created inside the viewer.
|
||||
window._reloadAnnotations = _loadAnnotations;
|
||||
|
||||
var form = document.getElementById('annotation-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
@@ -81,10 +85,18 @@
|
||||
typeBadge.className = 'annotation-type annotation-type--' + ann.annotation_type;
|
||||
typeBadge.textContent = _i18n['type_' + ann.annotation_type] || ann.annotation_type;
|
||||
|
||||
var pageInfo = document.createElement('span');
|
||||
pageInfo.className = 'annotation-page';
|
||||
var pageInfo = document.createElement('button');
|
||||
pageInfo.type = 'button';
|
||||
pageInfo.className = 'annotation-page annotation-page--link';
|
||||
pageInfo.setAttribute('aria-label', (_i18n.go_to_page || 'Go to page') + ' ' + ann.page);
|
||||
pageInfo.title = (_i18n.go_to_page || 'Go to page') + ' ' + ann.page;
|
||||
pageInfo.innerHTML = '<i class="fas fa-file-alt" aria-hidden="true"></i> ' +
|
||||
(_i18n.page || 'Page') + ' ' + ann.page;
|
||||
pageInfo.addEventListener('click', function () {
|
||||
if (typeof window._embedpdfScrollToPage === 'function') {
|
||||
window._embedpdfScrollToPage(ann.page);
|
||||
}
|
||||
});
|
||||
|
||||
header.appendChild(typeBadge);
|
||||
if (ann.color) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* claim.js — Claim-ownership UI helper for unowned documents.
|
||||
*
|
||||
* Usage: call initClaimOwnership(fileId, i18n) after DOMContentLoaded.
|
||||
* The i18n object must contain:
|
||||
* confirm, success, failed
|
||||
*/
|
||||
function initClaimOwnership(fileId, i18n) {
|
||||
var btn = document.getElementById('claim-btn');
|
||||
var msg = document.getElementById('claim-msg');
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
if (!confirm(i18n.confirm)) return;
|
||||
btn.disabled = true;
|
||||
fetch('/api/files/' + fileId + '/claim', { method: 'POST' })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
|
||||
.then(function (result) {
|
||||
if (result.ok || (result.data && result.data.status === 'already_owned')) {
|
||||
if (msg) {
|
||||
msg.textContent = i18n.success;
|
||||
msg.style.color = '#059669';
|
||||
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
|
||||
}
|
||||
setTimeout(function () { location.reload(); }, 1200);
|
||||
} else {
|
||||
if (msg) {
|
||||
msg.textContent = (result.data && result.data.detail) || i18n.failed;
|
||||
msg.style.color = '#dc2626';
|
||||
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (msg) {
|
||||
msg.textContent = i18n.failed;
|
||||
msg.style.color = '#dc2626';
|
||||
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
|
||||
}
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
+2
-239
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./templates/**/*.html',
|
||||
'./static/js/**/*.js',
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -18,11 +18,11 @@
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.sso_auto_login_description") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="sso-auto-login-toggle" class="sr-only peer"
|
||||
<label class="doc-toggle">
|
||||
<input type="checkbox" id="sso-auto-login-toggle" class="sr-only"
|
||||
{% if sso_auto_login %}checked{% endif %}
|
||||
onchange="toggleSetting('sso_auto_login', this.checked)">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600" style="min-width:44px; min-height:24px;"></div>
|
||||
<span class="doc-toggle-track" aria-hidden="true"></span>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.sso_auto_login") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -44,10 +44,11 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="qr-upload-toggle" class="sr-only peer"
|
||||
{% if qr_login_enabled %}checked{% endif %} disabled>
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600" style="min-width:44px; min-height:24px;"></div>
|
||||
<label class="doc-toggle">
|
||||
<input type="checkbox" id="qr-upload-toggle" class="sr-only"
|
||||
{% if qr_login_enabled %}checked{% endif %}
|
||||
onchange="toggleSetting('qr_login_enabled', this.checked)">
|
||||
<span class="doc-toggle-track" aria-hidden="true"></span>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.qr_code_enabled") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -199,24 +200,24 @@ function openServiceModal(serviceKey) {
|
||||
}
|
||||
|
||||
if (meta.type === 'boolean') {
|
||||
// Styled Tailwind toggle switch (matches the page-level toggles)
|
||||
// Styled toggle switch using .doc-toggle CSS class (compatible with Tailwind v2 CDN).
|
||||
const toggleWrapper = document.createElement('label');
|
||||
toggleWrapper.className = 'relative inline-flex items-center cursor-pointer';
|
||||
toggleWrapper.className = 'doc-toggle';
|
||||
toggleWrapper.setAttribute('aria-label', field.key.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); }));
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.id = 'field-' + field.key;
|
||||
checkbox.name = field.key;
|
||||
checkbox.className = 'sr-only peer';
|
||||
checkbox.className = 'sr-only';
|
||||
const val = field.value;
|
||||
if (val === true || val === 'true' || val === '1' || val === 'True') {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
|
||||
const slider = document.createElement('div');
|
||||
slider.className = "w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600";
|
||||
slider.style.cssText = 'min-width:44px; min-height:24px;';
|
||||
const slider = document.createElement('span');
|
||||
slider.className = 'doc-toggle-track';
|
||||
slider.setAttribute('aria-hidden', 'true');
|
||||
|
||||
toggleWrapper.appendChild(checkbox);
|
||||
toggleWrapper.appendChild(slider);
|
||||
|
||||
@@ -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=="
|
||||
@@ -41,8 +39,10 @@
|
||||
upgrading the SDK.
|
||||
──────────────────────────────────────────────────────────────────────── #}
|
||||
{% if sentry_dsn %}
|
||||
<script src="https://browser.sentry-cdn.com/9.x.x/bundle.tracing.replay.min.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://browser.sentry-cdn.com/10.45.0/bundle.tracing.replay.feedback.logs.metrics.min.js"
|
||||
integrity="sha384-TCY3xw5Ej940LIWfS6PwhCCBl7lvEsxBpHy+BirF+EycSQUvXbfZsgsLi0oU18yZ"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script>
|
||||
if (window.Sentry) {
|
||||
Sentry.init({
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -380,6 +380,23 @@
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
}
|
||||
.annotation-page--link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
text-decoration: none;
|
||||
min-height: 0;
|
||||
}
|
||||
.annotation-page--link:hover {
|
||||
color: #3182ce;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.dark .annotation-page--link:hover {
|
||||
color: #63b3ed;
|
||||
}
|
||||
.annotation-content {
|
||||
color: #4a5568;
|
||||
font-size: 0.9375rem;
|
||||
@@ -569,6 +586,23 @@
|
||||
Comments & Annotations
|
||||
</div>
|
||||
<div class="annotations-subtitle">{{ file.original_filename }}</div>
|
||||
{% if multi_user_enabled %}
|
||||
<div class="annotations-subtitle" style="margin-top:0.25rem;">
|
||||
<i class="fas fa-user" aria-hidden="true" style="margin-right:0.25rem;"></i>
|
||||
{{ _("file.owner_label") }}: <strong>{{ owner_display or _("file.owner_unowned") }}</strong>
|
||||
{% if file.owner_id is none %}
|
||||
—
|
||||
<button
|
||||
id="claim-btn"
|
||||
aria-label="{{ _('file.claim_ownership') }}"
|
||||
style="background:#10b981;color:#fff;border:none;border-radius:0.375rem;padding:0.25rem 0.75rem;font-size:0.8rem;font-weight:600;cursor:pointer;"
|
||||
>
|
||||
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
|
||||
</button>
|
||||
<span id="claim-msg" style="font-size:0.8rem;margin-left:0.5rem;display:none;" role="alert"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -750,6 +784,7 @@
|
||||
delete_confirm: {{ _("annotations.delete_confirm") | tojson }},
|
||||
page: {{ _("annotations.page") | tojson }},
|
||||
color: {{ _("annotations.color") | tojson }},
|
||||
go_to_page: {{ _("annotations.go_to_page") | tojson }},
|
||||
type_note: {{ _("annotations.type_note") | tojson }},
|
||||
type_highlight: {{ _("annotations.type_highlight") | tojson }},
|
||||
type_underline: {{ _("annotations.type_underline") | tojson }},
|
||||
@@ -801,18 +836,110 @@
|
||||
|
||||
const viewerEl = document.getElementById('embedpdf-viewer');
|
||||
if (viewerEl) {
|
||||
const fileId = {{ file.id | tojson }};
|
||||
{% if processed_file_exists %}
|
||||
const pdfUrl = '/api/files/{{ file.id }}/preview?version=processed';
|
||||
const pdfUrl = '/api/files/' + fileId + '/preview?version=processed';
|
||||
{% else %}
|
||||
const pdfUrl = '/api/files/{{ file.id }}/preview?version=original';
|
||||
const pdfUrl = '/api/files/' + fileId + '/preview?version=original';
|
||||
{% endif %}
|
||||
|
||||
EmbedPDF.init({
|
||||
const viewer = EmbedPDF.init({
|
||||
type: 'container',
|
||||
target: viewerEl,
|
||||
src: pdfUrl,
|
||||
});
|
||||
|
||||
if (viewer) {
|
||||
viewer.registry.then(function (registry) {
|
||||
// ── Page sync: viewer page change → update annotation form ──────────
|
||||
var scrollPlugin = registry.getPlugin('scroll');
|
||||
if (scrollPlugin) {
|
||||
var scroll = scrollPlugin.provides();
|
||||
scroll.onPageChange(function (event) {
|
||||
var pageInput = document.getElementById('annotation-page-input');
|
||||
if (pageInput) {
|
||||
pageInput.value = String(event.pageNumber);
|
||||
}
|
||||
});
|
||||
// Expose scrollToPage so the annotations panel can navigate the viewer
|
||||
window._embedpdfScrollToPage = function (pageNumber) {
|
||||
scroll.scrollToPage({ pageNumber: pageNumber });
|
||||
};
|
||||
}
|
||||
|
||||
// ── Auto-save: viewer annotation events → DocuElevate API ───────────
|
||||
var annotationPlugin = registry.getPlugin('annotation');
|
||||
if (annotationPlugin) {
|
||||
var annotation = annotationPlugin.provides();
|
||||
annotation.onAnnotationEvent(function (event) {
|
||||
if (event.type !== 'create') return;
|
||||
var ann = event.annotation;
|
||||
var pageIndex = typeof ann.pageIndex === 'number' ? ann.pageIndex
|
||||
: (typeof event.pageIndex === 'number' ? event.pageIndex : 0);
|
||||
var page = pageIndex + 1;
|
||||
var rect = ann.rect || { x: 0, y: 0, width: 0, height: 0 };
|
||||
var color = ann.strokeColor || ann.color || undefined;
|
||||
var content = (ann.contents || '').trim();
|
||||
// Map PDF annotation subtypes to DocuElevate annotation types
|
||||
var typeMap = {
|
||||
highlight: 'highlight',
|
||||
underline: 'underline',
|
||||
strikeout: 'strikethrough',
|
||||
squiggly: 'underline',
|
||||
text: 'note',
|
||||
freetext: 'note',
|
||||
ink: 'note',
|
||||
square: 'note',
|
||||
circle: 'note',
|
||||
};
|
||||
var annType = typeMap[String(ann.type).toLowerCase()] || 'note';
|
||||
if (!content) {
|
||||
var typeLabel = annType.charAt(0).toUpperCase() + annType.slice(1);
|
||||
content = typeLabel + ' \u2014 p.' + page;
|
||||
}
|
||||
var payload = {
|
||||
page: page,
|
||||
x: rect.x || 0,
|
||||
y: rect.y || 0,
|
||||
width: rect.width || 0,
|
||||
height: rect.height || 0,
|
||||
annotation_type: annType,
|
||||
content: content,
|
||||
};
|
||||
if (color) {
|
||||
payload.color = color;
|
||||
}
|
||||
fetch('/api/files/' + fileId + '/annotations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.ok && typeof window._reloadAnnotations === 'function') {
|
||||
window._reloadAnnotations();
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error('Failed to save viewer annotation:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if multi_user_enabled and file and file.owner_id is none %}
|
||||
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initClaimOwnership({{ file.id | tojson }}, {
|
||||
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
|
||||
success: {{ _("file.claim_ownership_success") | tojson }},
|
||||
failed: {{ _("file.claim_ownership_failed") | tojson }}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -165,6 +165,12 @@
|
||||
{% if file.document_title %}
|
||||
<div class="info-row"><span class="info-key">Document Title</span><span class="info-val">{{ file.document_title }}</span></div>
|
||||
{% endif %}
|
||||
{% if multi_user_enabled %}
|
||||
<div class="info-row">
|
||||
<span class="info-key">{{ _("file.owner_label") }}</span>
|
||||
<span class="info-val">{{ owner_display or _("file.owner_unowned") }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ── Quick actions ── -->
|
||||
@@ -184,7 +190,18 @@
|
||||
<a href="/files/{{ file.id }}/detail" class="action-btn btn-secondary">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i> View Detail
|
||||
</a>
|
||||
{% if multi_user_enabled and file.owner_id is none %}
|
||||
<button
|
||||
class="action-btn btn-primary"
|
||||
id="claim-btn"
|
||||
aria-label="{{ _('file.claim_ownership') }}"
|
||||
style="background:#10b981;"
|
||||
>
|
||||
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p id="claim-msg" style="margin-top:0.5rem;font-size:0.875rem;display:none;" role="alert"></p>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
@@ -193,4 +210,17 @@
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% if multi_user_enabled and file and file.owner_id is none %}
|
||||
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initClaimOwnership({{ file.id | tojson }}, {
|
||||
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
|
||||
success: {{ _("file.claim_ownership_success") | tojson }},
|
||||
failed: {{ _("file.claim_ownership_failed") | tojson }}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -316,6 +316,12 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% if multi_user_enabled %}
|
||||
<div class="info-row">
|
||||
<span class="info-key">{{ _("file.owner_label") }}</span>
|
||||
<span class="info-val">{{ owner_display or _("file.owner_unowned") }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
@@ -344,7 +350,18 @@
|
||||
<a href="/shared-links?file_id={{ file.id }}" class="action-btn btn-secondary">
|
||||
<i class="fas fa-share-alt" aria-hidden="true"></i> Share
|
||||
</a>
|
||||
{% if multi_user_enabled and file.owner_id is none %}
|
||||
<button
|
||||
class="action-btn btn-primary"
|
||||
id="claim-btn"
|
||||
aria-label="{{ _('file.claim_ownership') }}"
|
||||
style="background:#10b981;border:none;cursor:pointer;"
|
||||
>
|
||||
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p id="claim-msg" style="margin-top:0.5rem;font-size:0.875rem;display:none;" role="alert"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -937,6 +954,16 @@
|
||||
pdfInit('/api/files/{{ file.id }}/preview?version={{ pv }}');
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if multi_user_enabled and file and file.owner_id is none %}
|
||||
initClaimOwnership({{ file.id | tojson }}, {
|
||||
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
|
||||
success: {{ _("file.claim_ownership_success") | tojson }},
|
||||
failed: {{ _("file.claim_ownership_failed") | tojson }}
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% if multi_user_enabled and file and file.owner_id is none %}
|
||||
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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=="
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -321,6 +321,7 @@
|
||||
"annotations.delete_confirm": "Are you sure you want to delete this annotation?",
|
||||
"annotations.deleted": "Annotation deleted",
|
||||
"annotations.empty": "No annotations yet",
|
||||
"annotations.go_to_page": "Go to page",
|
||||
"annotations.heading": "Annotations",
|
||||
"annotations.page": "Page",
|
||||
"annotations.save": "Save",
|
||||
@@ -1756,6 +1757,12 @@
|
||||
"sharing.role_viewer": "Viewer",
|
||||
"sharing.user_id_label": "User ID or email",
|
||||
"sharing.user_id_placeholder": "e.g. alice@example.com",
|
||||
"file.owner_label": "Owner",
|
||||
"file.owner_unowned": "Unowned",
|
||||
"file.claim_ownership": "Claim Ownership",
|
||||
"file.claim_ownership_confirm": "Claim this document as yours? You will become the owner and can manage sharing.",
|
||||
"file.claim_ownership_success": "You are now the owner of this document.",
|
||||
"file.claim_ownership_failed": "Could not claim ownership. The document may already have an owner.",
|
||||
"similarity.backfill_auto": "The background task will compute them automatically every 5 minutes, or you can",
|
||||
"similarity.files_missing_text": "file(s) have OCR text but no embedding yet.",
|
||||
"similarity.find_pairs_btn": "Find Pairs",
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -156,6 +156,49 @@ class TestCommentsUIRendering:
|
||||
assert 'id="embedpdf-viewer"' in html
|
||||
assert "@embedpdf/snippet" in html
|
||||
|
||||
def test_embedpdf_init_subscribes_to_page_change(self, client: TestClient, db_session, tmp_path):
|
||||
"""The EmbedPDF init script should subscribe to page change events to sync the form."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/annotations")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
# Verifies the viewer registry is awaited and scroll plugin is used
|
||||
assert "viewer.registry" in html
|
||||
assert "onPageChange" in html
|
||||
assert "annotation-page-input" in html
|
||||
|
||||
def test_embedpdf_init_exposes_scroll_function(self, client: TestClient, db_session, tmp_path):
|
||||
"""The EmbedPDF init script must expose _embedpdfScrollToPage for the annotations panel."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/annotations")
|
||||
assert resp.status_code == 200
|
||||
assert "_embedpdfScrollToPage" in resp.text
|
||||
assert "scrollToPage" in resp.text
|
||||
|
||||
def test_embedpdf_init_saves_viewer_annotations(self, client: TestClient, db_session, tmp_path):
|
||||
"""The EmbedPDF init script should capture annotation events and POST to the API."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/annotations")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert "onAnnotationEvent" in html
|
||||
# Verifies the POST target is the annotations API for this file
|
||||
assert "/api/files/" in html and "/annotations" in html
|
||||
|
||||
def test_embedpdf_init_reloads_annotation_list(self, client: TestClient, db_session, tmp_path):
|
||||
"""After auto-saving a viewer annotation, the panel list should be refreshed."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/annotations")
|
||||
assert resp.status_code == 200
|
||||
assert "_reloadAnnotations" in resp.text
|
||||
|
||||
def test_annotations_page_has_go_to_page_i18n(self, client: TestClient, db_session, tmp_path):
|
||||
"""The annotations i18n bundle should include the go_to_page key."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/annotations")
|
||||
assert resp.status_code == 200
|
||||
assert "go_to_page" in resp.text
|
||||
|
||||
def test_summary_page_renders(self, client: TestClient, db_session, tmp_path):
|
||||
"""The summary page at /files/{id} should render correctly."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
|
||||
+169
-2
@@ -268,8 +268,6 @@ class TestConnectionsPageRoute:
|
||||
patch("app.views.settings.get_all_settings_from_db", return_value={}),
|
||||
patch("app.views.settings.templates") as mock_templates,
|
||||
patch("app.views.settings.SETTING_METADATA", {}),
|
||||
patch("app.auth.OAUTH_CONFIGURED", False),
|
||||
patch("app.auth.SOCIAL_PROVIDERS", {}),
|
||||
patch("app.views.settings.get_setting_metadata", return_value={}),
|
||||
):
|
||||
mock_templates.TemplateResponse.return_value = "response"
|
||||
@@ -296,6 +294,175 @@ class TestConnectionsPageRoute:
|
||||
assert "smtp" in service_keys
|
||||
assert "telegram" in service_keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_page_linked_status_from_db(self):
|
||||
"""Linked status is derived from DB/effective settings, not SOCIAL_PROVIDERS."""
|
||||
from app.views.settings import connections_page
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {"user": {"is_admin": True}}
|
||||
mock_db = MagicMock()
|
||||
|
||||
# Simulate GitHub configured only in DB (not in SOCIAL_PROVIDERS yet)
|
||||
db_values = {
|
||||
"social_auth_github_enabled": "true",
|
||||
"social_auth_github_client_id": "gh-id",
|
||||
"social_auth_github_client_secret": "gh-secret",
|
||||
}
|
||||
|
||||
with (
|
||||
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
|
||||
patch("app.views.settings.templates") as mock_templates,
|
||||
patch("app.views.settings.SETTING_METADATA", {}),
|
||||
patch("app.views.settings.get_setting_metadata", return_value={}),
|
||||
):
|
||||
mock_templates.TemplateResponse.return_value = "response"
|
||||
await connections_page(mock_request, db=mock_db)
|
||||
|
||||
context = mock_templates.TemplateResponse.call_args[0][1]
|
||||
services_by_key = {s["key"]: s for s in context["services"]}
|
||||
|
||||
# GitHub should be linked because DB values say so
|
||||
assert services_by_key["github"]["linked"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_page_unlinked_when_credentials_missing(self):
|
||||
"""Provider is unlinked when enabled=true but credentials are absent."""
|
||||
from app.views.settings import connections_page
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {"user": {"is_admin": True}}
|
||||
mock_db = MagicMock()
|
||||
|
||||
# enabled but no credentials
|
||||
db_values = {"social_auth_github_enabled": "true"}
|
||||
|
||||
with (
|
||||
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
|
||||
patch("app.views.settings.templates") as mock_templates,
|
||||
patch("app.views.settings.SETTING_METADATA", {}),
|
||||
patch("app.views.settings.get_setting_metadata", return_value={}),
|
||||
):
|
||||
mock_templates.TemplateResponse.return_value = "response"
|
||||
await connections_page(mock_request, db=mock_db)
|
||||
|
||||
context = mock_templates.TemplateResponse.call_args[0][1]
|
||||
services_by_key = {s["key"]: s for s in context["services"]}
|
||||
assert services_by_key["github"]["linked"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_page_oidc_linked_from_db(self):
|
||||
"""OIDC linked status derives from DB effective settings."""
|
||||
from app.views.settings import connections_page
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {"user": {"is_admin": True}}
|
||||
mock_db = MagicMock()
|
||||
|
||||
db_values = {
|
||||
"authentik_client_id": "my-client-id",
|
||||
"authentik_client_secret": "my-secret",
|
||||
"oauth_provider_name": "My SSO",
|
||||
}
|
||||
|
||||
with (
|
||||
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
|
||||
patch("app.views.settings.templates") as mock_templates,
|
||||
patch("app.views.settings.SETTING_METADATA", {}),
|
||||
patch("app.views.settings.get_setting_metadata", return_value={}),
|
||||
):
|
||||
mock_templates.TemplateResponse.return_value = "response"
|
||||
await connections_page(mock_request, db=mock_db)
|
||||
|
||||
context = mock_templates.TemplateResponse.call_args[0][1]
|
||||
services_by_key = {s["key"]: s for s in context["services"]}
|
||||
assert services_by_key["oidc"]["linked"] is True
|
||||
assert services_by_key["oidc"]["name"] == "My SSO"
|
||||
# oauth_configured template var should also reflect the DB state
|
||||
assert context["oauth_configured"] is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRefreshSocialProviders:
|
||||
"""Tests for the refresh_social_providers() mechanism."""
|
||||
|
||||
def test_refresh_social_providers_exists(self):
|
||||
"""refresh_social_providers is importable from app.auth."""
|
||||
from app.auth import refresh_social_providers
|
||||
|
||||
assert callable(refresh_social_providers)
|
||||
|
||||
def test_refresh_social_providers_clears_and_repopulates(self):
|
||||
"""After refresh, SOCIAL_PROVIDERS reflects current settings."""
|
||||
import app.auth as auth_module
|
||||
|
||||
with (
|
||||
patch.object(auth_module, "AUTH_ENABLED", True),
|
||||
patch.object(auth_module, "settings") as mock_settings,
|
||||
):
|
||||
mock_settings.authentik_client_id = None
|
||||
mock_settings.authentik_client_secret = None
|
||||
mock_settings.social_auth_google_enabled = True
|
||||
mock_settings.social_auth_google_client_id = "gid"
|
||||
mock_settings.social_auth_google_client_secret = "gsecret"
|
||||
mock_settings.social_auth_google_use_global_credentials = False
|
||||
# All other providers disabled
|
||||
for attr in (
|
||||
"social_auth_microsoft_enabled",
|
||||
"social_auth_apple_enabled",
|
||||
"social_auth_dropbox_enabled",
|
||||
"social_auth_github_enabled",
|
||||
"social_auth_keycloak_enabled",
|
||||
"social_auth_generic_oauth2_enabled",
|
||||
):
|
||||
setattr(mock_settings, attr, False)
|
||||
|
||||
with patch.object(auth_module, "_register_oauth_client"):
|
||||
auth_module._setup_social_providers()
|
||||
|
||||
assert "google" in auth_module.SOCIAL_PROVIDERS
|
||||
assert auth_module.OAUTH_CONFIGURED is False
|
||||
|
||||
def test_refresh_clears_previous_providers(self):
|
||||
"""Providers removed from settings are cleared after refresh."""
|
||||
import app.auth as auth_module
|
||||
|
||||
# Pre-populate with a stale entry
|
||||
auth_module.SOCIAL_PROVIDERS["stale_provider"] = {"name": "Stale", "icon": "", "color": ""}
|
||||
|
||||
with (
|
||||
patch.object(auth_module, "AUTH_ENABLED", True),
|
||||
patch.object(auth_module, "settings") as mock_settings,
|
||||
):
|
||||
mock_settings.authentik_client_id = None
|
||||
mock_settings.authentik_client_secret = None
|
||||
for attr in (
|
||||
"social_auth_google_enabled",
|
||||
"social_auth_microsoft_enabled",
|
||||
"social_auth_apple_enabled",
|
||||
"social_auth_dropbox_enabled",
|
||||
"social_auth_github_enabled",
|
||||
"social_auth_keycloak_enabled",
|
||||
"social_auth_generic_oauth2_enabled",
|
||||
):
|
||||
setattr(mock_settings, attr, False)
|
||||
|
||||
with patch.object(auth_module, "_register_oauth_client"):
|
||||
auth_module._setup_social_providers()
|
||||
|
||||
assert "stale_provider" not in auth_module.SOCIAL_PROVIDERS
|
||||
|
||||
def test_register_oauth_client_clears_cache(self):
|
||||
"""_register_oauth_client removes the cached client before re-registering."""
|
||||
import app.auth as auth_module
|
||||
|
||||
# Inject a fake cached client
|
||||
auth_module.oauth._clients["test_provider"] = object()
|
||||
|
||||
with patch.object(auth_module.oauth, "register"):
|
||||
auth_module._register_oauth_client("test_provider", client_id="x", client_secret="y")
|
||||
assert "test_provider" not in auth_module.oauth._clients
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestTranslationKeys:
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)"""
|
||||
|
||||
@@ -6,6 +6,7 @@ Target: Bring coverage from 8.77% to 70%+
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@@ -1981,3 +1982,128 @@ class TestPipelineInfoInViews:
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"Standard" in response.content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Owner display and claim ownership tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOwnerDisplayAndClaim:
|
||||
"""Tests that owner info and claim button appear correctly on file views."""
|
||||
|
||||
def _make_file(self, db_session, owner_id=None) -> FileRecord:
|
||||
file_rec = FileRecord(
|
||||
filehash=uuid.uuid4().hex,
|
||||
original_filename="doc.pdf",
|
||||
local_filename="/tmp/doc.pdf",
|
||||
file_size=512,
|
||||
mime_type="application/pdf",
|
||||
owner_id=owner_id,
|
||||
)
|
||||
db_session.add(file_rec)
|
||||
db_session.commit()
|
||||
db_session.refresh(file_rec)
|
||||
return file_rec
|
||||
|
||||
# ── /files/{id} (file_summary.html) ──────────────────────────────────
|
||||
|
||||
def test_summary_shows_owner_when_multi_user_enabled(self, client, db_session):
|
||||
"""Owner ID is rendered in file summary when multi-user mode is on."""
|
||||
file_rec = self._make_file(db_session, owner_id="alice@example.com")
|
||||
with patch("app.config.settings.multi_user_enabled", True):
|
||||
response = client.get(f"/files/{file_rec.id}")
|
||||
assert response.status_code == 200
|
||||
assert b"alice@example.com" in response.content
|
||||
|
||||
def test_summary_shows_unowned_label_for_unowned_file(self, client, db_session):
|
||||
"""'Unowned' label is rendered in file summary for files without an owner."""
|
||||
file_rec = self._make_file(db_session, owner_id=None)
|
||||
with patch("app.config.settings.multi_user_enabled", True):
|
||||
response = client.get(f"/files/{file_rec.id}")
|
||||
assert response.status_code == 200
|
||||
assert b"Unowned" in response.content
|
||||
|
||||
def test_summary_shows_claim_button_for_unowned_file(self, client, db_session):
|
||||
"""Claim Ownership button appears on file summary for an unowned file."""
|
||||
file_rec = self._make_file(db_session, owner_id=None)
|
||||
with patch("app.config.settings.multi_user_enabled", True):
|
||||
response = client.get(f"/files/{file_rec.id}")
|
||||
assert response.status_code == 200
|
||||
assert b"Claim Ownership" in response.content
|
||||
|
||||
def test_summary_no_claim_button_when_owned(self, client, db_session):
|
||||
"""No Claim Ownership button when the file already has an owner."""
|
||||
file_rec = self._make_file(db_session, owner_id="bob@example.com")
|
||||
with patch("app.config.settings.multi_user_enabled", True):
|
||||
response = client.get(f"/files/{file_rec.id}")
|
||||
assert response.status_code == 200
|
||||
assert b"Claim Ownership" not in response.content
|
||||
|
||||
def test_summary_no_owner_row_in_single_user_mode(self, client, db_session):
|
||||
"""Owner row is hidden in single-user mode."""
|
||||
file_rec = self._make_file(db_session, owner_id=None)
|
||||
with patch("app.config.settings.multi_user_enabled", False):
|
||||
response = client.get(f"/files/{file_rec.id}")
|
||||
assert response.status_code == 200
|
||||
# Claim button and Unowned label should not appear in single-user mode
|
||||
assert b"Claim Ownership" not in response.content
|
||||
|
||||
# ── /files/{id}/detail (file_view.html) ──────────────────────────────
|
||||
|
||||
def test_detail_shows_owner_when_multi_user_enabled(self, client, db_session):
|
||||
"""Owner ID is rendered in file detail view when multi-user mode is on."""
|
||||
file_rec = self._make_file(db_session, owner_id="charlie@example.com")
|
||||
with patch("app.config.settings.multi_user_enabled", True):
|
||||
response = client.get(f"/files/{file_rec.id}/detail")
|
||||
assert response.status_code == 200
|
||||
assert b"charlie@example.com" in response.content
|
||||
|
||||
def test_detail_shows_claim_button_for_unowned_file(self, client, db_session):
|
||||
"""Claim Ownership button appears in file detail view for an unowned file."""
|
||||
file_rec = self._make_file(db_session, owner_id=None)
|
||||
with patch("app.config.settings.multi_user_enabled", True):
|
||||
response = client.get(f"/files/{file_rec.id}/detail")
|
||||
assert response.status_code == 200
|
||||
assert b"Claim Ownership" in response.content
|
||||
|
||||
# ── /files/{id}/annotations (file_annotations.html) ──────────────────
|
||||
|
||||
def test_annotations_shows_owner_info(self, client, db_session):
|
||||
"""Owner info is rendered on the annotations page in multi-user mode."""
|
||||
file_rec = self._make_file(db_session, owner_id="dave@example.com")
|
||||
with patch("app.config.settings.multi_user_enabled", True):
|
||||
response = client.get(f"/files/{file_rec.id}/annotations")
|
||||
assert response.status_code == 200
|
||||
assert b"dave@example.com" in response.content
|
||||
|
||||
def test_annotations_shows_claim_button_for_unowned_file(self, client, db_session):
|
||||
"""Claim Ownership button appears on annotations page for unowned file."""
|
||||
file_rec = self._make_file(db_session, owner_id=None)
|
||||
with patch("app.config.settings.multi_user_enabled", True):
|
||||
response = client.get(f"/files/{file_rec.id}/annotations")
|
||||
assert response.status_code == 200
|
||||
assert b"Claim Ownership" in response.content
|
||||
|
||||
def test_annotations_no_claim_button_when_owned(self, client, db_session):
|
||||
"""No Claim Ownership button on annotations page when file has an owner."""
|
||||
file_rec = self._make_file(db_session, owner_id="eve@example.com")
|
||||
with patch("app.config.settings.multi_user_enabled", True):
|
||||
response = client.get(f"/files/{file_rec.id}/annotations")
|
||||
assert response.status_code == 200
|
||||
assert b"Claim Ownership" not in response.content
|
||||
|
||||
def test_display_name_used_when_profile_exists(self, client, db_session):
|
||||
"""UserProfile.display_name overrides raw user_id in the owner display."""
|
||||
from app.models import UserProfile
|
||||
|
||||
file_rec = self._make_file(db_session, owner_id="frank@example.com")
|
||||
profile = UserProfile(user_id="frank@example.com", display_name="Frank Lastname")
|
||||
db_session.add(profile)
|
||||
db_session.commit()
|
||||
|
||||
with patch("app.config.settings.multi_user_enabled", True):
|
||||
response = client.get(f"/files/{file_rec.id}")
|
||||
assert response.status_code == 200
|
||||
assert b"Frank Lastname" in response.content
|
||||
|
||||
Reference in New Issue
Block a user