Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4def11d976 | ||
|
|
b2babfe382 | ||
|
|
4755393466 | ||
|
|
acc8c0658d | ||
|
|
3f459873fc | ||
|
|
1f4ec43974 | ||
|
|
fc922b6144 | ||
|
|
52ff0d153e | ||
|
|
2f54fb33cd | ||
|
|
8715be9087 | ||
|
|
b19cbd7608 |
@@ -46,14 +46,15 @@ TOP_K_SUMMARY=4
|
||||
MAX_CONTEXT_CHARS=4000
|
||||
MIN_SIMILARITY_SCORE=0.18
|
||||
LOG_LEVEL=INFO
|
||||
CORS_ORIGINS=https://docqube-test.maskantech.in,https://docqubeapp-test.maskantech.in,https://docqubeapi-test.maskantech.in
|
||||
CORS_ORIGINS=https://saas-test.maskantech.in,https://docqubeapp-test.maskantech.in,https://docqube-test.maskantech.in,http://localhost:5173,http://localhost:3000
|
||||
COOKIE_DOMAIN=.maskantech.in
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=info.maskantech@gmail.com
|
||||
SMTP_PASSWORD=tuthpljtkrwchgxd
|
||||
MAIL_FROM=info.maskantech@gmail.com
|
||||
SMTP_SECURE=True
|
||||
FRONTEND_URL=https://docqube-test.maskantech.in,https://docqubeapp-test.maskantech.in,https://docqubeapi-test.maskantech.in
|
||||
FRONTEND_URL=https://docqubeapp-test.maskantech.in
|
||||
CHAT_REDIS_MAX_MESSAGES=10
|
||||
CHAT_REDIS_TTL_SECONDS=86400
|
||||
CHAT_DAILY_CREDITS_LIMIT=1000
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ pip-wheel-metadata/
|
||||
# Application runtime data
|
||||
app/temp_uploads/*
|
||||
storage_drive/
|
||||
storage/
|
||||
/storage/
|
||||
uploads/
|
||||
downloads/
|
||||
temp/
|
||||
|
||||
+8
-1
@@ -44,7 +44,14 @@ def send_email(
|
||||
if template_name and template_context is not None and jinja_env:
|
||||
try:
|
||||
template = jinja_env.get_template(template_name)
|
||||
html_body = template.render(**template_context)
|
||||
# asset_base_url is injected for every template so image URLs follow
|
||||
# the deployed frontend instead of being hardcoded. A call site can
|
||||
# override it by passing its own value in template_context.
|
||||
context = {
|
||||
"asset_base_url": settings.EMAIL_ASSET_BASE_URL,
|
||||
**template_context,
|
||||
}
|
||||
html_body = template.render(**context)
|
||||
if not body:
|
||||
body = f"Please view this email in an HTML-compatible client. Subject: {subject}"
|
||||
except Exception as e:
|
||||
|
||||
@@ -196,6 +196,17 @@ class Settings(BaseSettings):
|
||||
|
||||
FRONTEND_URL: str = "http://localhost:5173"
|
||||
|
||||
@property
|
||||
def EMAIL_ASSET_BASE_URL(self) -> str:
|
||||
"""Origin serving the email images in docqube_frontend/public/email/.
|
||||
|
||||
These are frontend static assets, so they hang off FRONTEND_URL rather
|
||||
than the API. FRONTEND_URL is a comma-separated list in some
|
||||
environments (see .env.test), so take the first entry.
|
||||
"""
|
||||
base = self.FRONTEND_URL.split(",")[0].strip().rstrip("/")
|
||||
return f"{base}/email"
|
||||
|
||||
LOG_LEVEL: str
|
||||
|
||||
CORS_ORIGINS: str
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import logging
|
||||
import io
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from app.core.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# get_storage_client() was being called fresh on every single file operation
|
||||
# (every upload, download, stream, copy, ...), each paying for a DB round-trip
|
||||
# (TenantStorageConfig, sometimes SystemConfiguration too) plus a credential
|
||||
# decrypt and a new boto3 client construction — real, measured cost on a
|
||||
# remote DB, and a direct contributor to "opening any document takes a long
|
||||
# time." The (client, quarantine_bucket, clean_bucket) tuple is safe to reuse
|
||||
# across calls for the same tenant, so it's cached in-process with a short
|
||||
# TTL — long enough to eliminate the redundant work within a single document
|
||||
# open (which can call this several times), short enough that an admin
|
||||
# changing storage credentials in System Settings takes effect within a few
|
||||
# minutes without needing a server restart.
|
||||
_STORAGE_CLIENT_CACHE_TTL = 300 # seconds
|
||||
_storage_client_cache: dict = {}
|
||||
|
||||
|
||||
def invalidate_storage_client_cache(tenant_id=None) -> None:
|
||||
"""Drop cached storage client(s) so the next call rebuilds from the
|
||||
database — call this right after a tenant's storage config is
|
||||
created/updated/cleared, so admin credential changes take effect
|
||||
immediately instead of waiting out the TTL. Matches by string form
|
||||
since callers pass tenant_id as either a UUID or a str.
|
||||
"""
|
||||
if tenant_id is None:
|
||||
_storage_client_cache.clear()
|
||||
return
|
||||
for key in [k for k in _storage_client_cache if str(k[0]) == str(tenant_id)]:
|
||||
_storage_client_cache.pop(key, None)
|
||||
|
||||
class B2StorageClient:
|
||||
"""
|
||||
Backblaze B2 Storage Client using Boto3 (S3-compatible API).
|
||||
"""
|
||||
|
||||
def __init__(self, key_id: str, application_key: str, endpoint_url: str):
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
region = "us-west-004"
|
||||
if "s3." in endpoint_url:
|
||||
parts = endpoint_url.split("s3.")
|
||||
if len(parts) > 1:
|
||||
region = parts[1].split(".")[0]
|
||||
|
||||
try:
|
||||
self.client = boto3.client(
|
||||
's3',
|
||||
endpoint_url=endpoint_url,
|
||||
aws_access_key_id=key_id,
|
||||
aws_secret_access_key=application_key,
|
||||
region_name=region,
|
||||
config=Config(signature_version='s3v4')
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize B2 client: {e}")
|
||||
raise
|
||||
|
||||
def put_object(self, Bucket: str, Key: str, Body: Any, ContentType: str = "application/octet-stream", Metadata: Optional[dict] = None, length: Optional[int] = None):
|
||||
try:
|
||||
if isinstance(Body, bytes):
|
||||
data = Body
|
||||
elif hasattr(Body, "read"):
|
||||
data = Body
|
||||
else:
|
||||
data = Body
|
||||
|
||||
if Metadata:
|
||||
Metadata = {str(k): str(v) for k, v in Metadata.items()}
|
||||
else:
|
||||
Metadata = {}
|
||||
|
||||
self.client.put_object(
|
||||
Bucket=Bucket,
|
||||
Key=Key,
|
||||
Body=data,
|
||||
ContentType=ContentType,
|
||||
Metadata=Metadata
|
||||
)
|
||||
return {"status": "success", "key": Key}
|
||||
except Exception as e:
|
||||
logger.error(f"B2 Put Error: {e}")
|
||||
raise
|
||||
|
||||
def get_object(self, Bucket: str, Key: str, Range: Optional[str] = None):
|
||||
try:
|
||||
kwargs = {"Bucket": Bucket, "Key": Key}
|
||||
if Range:
|
||||
kwargs["Range"] = Range
|
||||
response = self.client.get_object(**kwargs)
|
||||
return {
|
||||
"Body": response['Body'],
|
||||
"ContentType": response.get('ContentType'),
|
||||
"ContentLength": response.get('ContentLength'),
|
||||
"ContentRange": response.get('ContentRange'),
|
||||
"StatusCode": 206 if Range else 200,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"B2 Get Error: {e}")
|
||||
raise
|
||||
|
||||
def delete_object(self, Bucket: str, Key: str):
|
||||
try:
|
||||
self.client.delete_object(Bucket=Bucket, Key=Key)
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
logger.error(f"B2 Delete Error: {e}")
|
||||
raise
|
||||
|
||||
def copy_object(self, Bucket: str, Key: str, CopySource: dict):
|
||||
try:
|
||||
self.client.copy_object(
|
||||
Bucket=Bucket,
|
||||
Key=Key,
|
||||
CopySource=CopySource
|
||||
)
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
logger.error(f"B2 Copy Error: {e}")
|
||||
raise
|
||||
|
||||
def generate_presigned_url(self, ClientMethod: str, Params: Optional[dict] = None, ExpiresIn: int = 3600, user_id: Optional[int] = None, tenant_id: Optional[str] = None):
|
||||
try:
|
||||
params = Params or {}
|
||||
key = params.get("Key")
|
||||
|
||||
if user_id is not None and key:
|
||||
allowed_prefixes = [
|
||||
f"user_{user_id}/", f"users/{user_id}/",
|
||||
f"chatbot_documents/{user_id}/", f"doc_conversion/{user_id}/",
|
||||
]
|
||||
if tenant_id:
|
||||
allowed_prefixes.extend([
|
||||
f"tenants/{tenant_id}/user_{user_id}/", f"tenants/{tenant_id}/users/{user_id}/",
|
||||
f"tenants/{tenant_id}/chatbot_documents/{user_id}/", f"tenants/{tenant_id}/doc_conversion/{user_id}/",
|
||||
])
|
||||
if not any(key.startswith(p) for p in allowed_prefixes) and not any(f"/{p}" in f"/{key}" for p in allowed_prefixes):
|
||||
logger.warning(f"🚨 Security Alert: User {user_id} attempted to generate presigned URL for unauthorized key: {key}")
|
||||
raise PermissionError(f"Access denied: User {user_id} does not own resource '{key}'")
|
||||
|
||||
if ClientMethod in ["get_object", "put_object"]:
|
||||
return self.client.generate_presigned_url(
|
||||
ClientMethod=ClientMethod,
|
||||
Params=Params,
|
||||
ExpiresIn=ExpiresIn
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
if not isinstance(e, PermissionError):
|
||||
logger.error(f"B2 Presigned URL Error: {e}")
|
||||
raise
|
||||
|
||||
def list_objects(self, Bucket: str, Prefix: str = ""):
|
||||
try:
|
||||
response = self.client.list_objects_v2(Bucket=Bucket, Prefix=Prefix)
|
||||
objects = response.get('Contents', [])
|
||||
return [{"Key": obj['Key'], "Size": obj['Size']} for obj in objects]
|
||||
except Exception as e:
|
||||
logger.error(f"B2 List Error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def format_bytes(size: float) -> str:
|
||||
"""Format bytes to human readable string."""
|
||||
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||
if size < 1024.0:
|
||||
return f"{size:.2f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.2f} PB"
|
||||
|
||||
|
||||
def generate_storage_key(tenant_id: str, user_id: int, filename: str) -> str:
|
||||
"""Generate a unique original storage key for an initial upload."""
|
||||
import uuid
|
||||
import os
|
||||
|
||||
tenant_part = f"tenants/{tenant_id}/" if tenant_id and tenant_id != 'None' else ""
|
||||
return f"{tenant_part}users/{user_id}/uploads/{uuid.uuid4()}_{filename}"
|
||||
|
||||
|
||||
def _derive_b2_endpoint(key_id: str) -> str:
|
||||
"""
|
||||
Derive the B2 S3 endpoint URL from the key ID.
|
||||
B2 key IDs start with a account cluster prefix.
|
||||
"""
|
||||
region_map = {
|
||||
"000": "us-west-000",
|
||||
"001": "us-west-001",
|
||||
"002": "us-west-002",
|
||||
"003": "us-west-004",
|
||||
"004": "us-west-004",
|
||||
"005": "us-east-005",
|
||||
}
|
||||
prefix = key_id[:3] if key_id and len(key_id) >= 3 else ""
|
||||
region = region_map.get(prefix, "us-west-004")
|
||||
|
||||
if key_id.startswith("003"):
|
||||
region = "us-west-004"
|
||||
elif key_id.startswith("005"):
|
||||
region = "us-east-005"
|
||||
|
||||
return f"https://s3.{region}.backblazeb2.com"
|
||||
|
||||
|
||||
def get_storage_client(db=None, tenant_id=None, provider: Optional[str] = None):
|
||||
"""
|
||||
Get storage client dynamically.
|
||||
1. Checks for tenant-specific configuration if tenant_id is provided.
|
||||
2. Falls back to global system configuration.
|
||||
Mandatory Backblaze B2.
|
||||
"""
|
||||
cache_key = (tenant_id, provider)
|
||||
cached = _storage_client_cache.get(cache_key)
|
||||
if cached is not None and time.time() - cached[3] < _STORAGE_CLIENT_CACHE_TTL:
|
||||
return cached[0], cached[1], cached[2]
|
||||
|
||||
if db is None:
|
||||
logger.error("get_storage_client called without DB session. Cannot fetch B2 config.")
|
||||
raise RuntimeError("Storage configuration requires a database session.")
|
||||
|
||||
from app.modules.configuration.models.system_configuration_model import SystemConfiguration
|
||||
from app.modules.tenant.models.tenant_storage_config_model import TenantStorageConfig
|
||||
from app.core.crypto import decrypt_data
|
||||
|
||||
if tenant_id:
|
||||
tenant_cfg = db.query(TenantStorageConfig).filter(
|
||||
TenantStorageConfig.tenant_id == tenant_id,
|
||||
TenantStorageConfig.is_active.is_(True)
|
||||
).first()
|
||||
|
||||
if tenant_cfg:
|
||||
try:
|
||||
key_id = (tenant_cfg.b2_key_id or "").strip()
|
||||
application_key = decrypt_data(tenant_cfg.encrypted_b2_application_key) if tenant_cfg.encrypted_b2_application_key else None
|
||||
|
||||
if not key_id or not application_key:
|
||||
logger.warning(f"[Storage] Tenant {tenant_id} configuration is incomplete (missing key or secret). Falling back to global.")
|
||||
else:
|
||||
endpoint = tenant_cfg.b2_endpoint
|
||||
if endpoint and endpoint.strip():
|
||||
endpoint = endpoint.strip()
|
||||
if not endpoint.startswith("http"):
|
||||
endpoint = f"https://{endpoint}"
|
||||
else:
|
||||
endpoint = _derive_b2_endpoint(key_id)
|
||||
|
||||
quarantine_bucket = tenant_cfg.b2_quarantine_bucket or "quarantine-bucket"
|
||||
clean_bucket = tenant_cfg.b2_clean_bucket or "clean-document-bucket"
|
||||
|
||||
logger.info(f"[Storage] Initializing B2 client for tenant {tenant_id} (Key ID: ...{key_id[-4:]})")
|
||||
|
||||
client = B2StorageClient(
|
||||
key_id=key_id,
|
||||
application_key=application_key,
|
||||
endpoint_url=endpoint
|
||||
)
|
||||
_storage_client_cache[cache_key] = (client, quarantine_bucket, clean_bucket, time.time())
|
||||
return client, quarantine_bucket, clean_bucket
|
||||
except Exception as e:
|
||||
logger.error(f"[Storage] Failed to initialize tenant storage for {tenant_id}: {e}. Falling back to global.")
|
||||
else:
|
||||
logger.debug(f"[Storage] No active tenant config for {tenant_id}, using global settings.")
|
||||
|
||||
config_items = db.query(SystemConfiguration).filter(SystemConfiguration.config_key.like("storage.%")).all()
|
||||
configs = {cfg.config_key: cfg for cfg in config_items}
|
||||
|
||||
def get_cfg_val(key, default=None):
|
||||
cfg = configs.get(key)
|
||||
return cfg.text_value if cfg and cfg.text_value is not None else default
|
||||
|
||||
key_id = get_cfg_val("storage.b2_key_id")
|
||||
enc_app_key = get_cfg_val("storage.b2_application_key")
|
||||
|
||||
if not key_id or not enc_app_key:
|
||||
logger.error("Backblaze B2 is not configured in system settings.")
|
||||
raise RuntimeError("Storage is not configured. Please set up Backblaze B2 in System Settings.")
|
||||
|
||||
try:
|
||||
key_id = key_id.strip()
|
||||
app_key = decrypt_data(enc_app_key).strip()
|
||||
|
||||
custom_endpoint = get_cfg_val("storage.b2_endpoint")
|
||||
if custom_endpoint and custom_endpoint.strip():
|
||||
endpoint = custom_endpoint.strip()
|
||||
if not endpoint.startswith("http"):
|
||||
endpoint = f"https://{endpoint}"
|
||||
else:
|
||||
endpoint = _derive_b2_endpoint(key_id)
|
||||
|
||||
quarantine = get_cfg_val("storage.b2_quarantine_bucket", "quarantine-bucket").strip()
|
||||
clean = get_cfg_val("storage.b2_clean_bucket", "clean-document-bucket").strip()
|
||||
|
||||
b2_client = B2StorageClient(key_id, app_key, endpoint_url=endpoint)
|
||||
logger.info(f"[Storage] Initializing global B2 client (Key: ...{key_id[-4:]})")
|
||||
_storage_client_cache[cache_key] = (b2_client, quarantine, clean, time.time())
|
||||
return b2_client, quarantine, clean
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Global B2 client: {e}")
|
||||
raise RuntimeError(f"Failed to initialize storage client: {e}")
|
||||
@@ -42,13 +42,15 @@ def _validate_saas_subscription(subscription_details: Optional[dict]) -> None:
|
||||
except ValueError:
|
||||
end_date = None
|
||||
|
||||
if is_active is False or status_value in {"INACTIVE", "EXPIRED"}:
|
||||
can_sign_in = subscription_details.get("can_sign_in", True)
|
||||
if can_sign_in is False or is_active is False or status_value in {"INACTIVE", "EXPIRED"}:
|
||||
raise HTTPException(status_code=403, detail="Tenant subscription is inactive")
|
||||
|
||||
if start_date and today < start_date:
|
||||
raise HTTPException(status_code=403, detail="Tenant subscription is not active yet")
|
||||
if not (can_sign_in and is_active and status_value == "ACTIVE"):
|
||||
if start_date and today < start_date:
|
||||
raise HTTPException(status_code=403, detail="Tenant subscription is not active yet")
|
||||
|
||||
if end_date and today > end_date:
|
||||
if end_date and today > end_date and not subscription_details.get("can_write", True):
|
||||
raise HTTPException(status_code=403, detail="Tenant subscription has expired")
|
||||
|
||||
|
||||
|
||||
@@ -34,25 +34,27 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
content={"detail": "CSRF verification failed"}
|
||||
)
|
||||
if not csrf_cookie:
|
||||
self._set_csrf_cookie(response)
|
||||
self._set_csrf_cookie(response, request)
|
||||
return response
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
if not csrf_cookie:
|
||||
self._set_csrf_cookie(response)
|
||||
self._set_csrf_cookie(response, request)
|
||||
|
||||
return response
|
||||
|
||||
def _set_csrf_cookie(self, response):
|
||||
def _set_csrf_cookie(self, response, request: Request = None):
|
||||
"""Helper to set the CSRF cookie with appropriate security flags."""
|
||||
is_prod = settings.APP_ENV == "production"
|
||||
is_secure = settings.APP_ENV in ["production", "test", "testing"] or (request and request.url.scheme == "https")
|
||||
samesite_mode = "none" if is_secure else "lax"
|
||||
cookie_domain = getattr(settings, "COOKIE_DOMAIN", None) or None
|
||||
response.set_cookie(
|
||||
key="csrf_token",
|
||||
value=str(uuid.uuid4()),
|
||||
httponly=False,
|
||||
samesite="none" if is_prod else "lax",
|
||||
secure=is_prod,
|
||||
domain=settings.COOKIE_DOMAIN if is_prod else None,
|
||||
samesite=samesite_mode,
|
||||
secure=is_secure,
|
||||
domain=cookie_domain,
|
||||
path="/"
|
||||
)
|
||||
@@ -45,13 +45,17 @@ def login(
|
||||
):
|
||||
token_data = AuthController.login_user(form_data, db, request)
|
||||
|
||||
is_secure = settings.APP_ENV in ["production", "test", "testing"] or request.url.scheme == "https"
|
||||
samesite_mode = "none" if is_secure else "lax"
|
||||
cookie_domain = getattr(settings, "COOKIE_DOMAIN", None) or None
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_access_token",
|
||||
value=token_data["access_token"],
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
max_age=3600,
|
||||
path="/",
|
||||
)
|
||||
@@ -60,9 +64,9 @@ def login(
|
||||
key="docqube_refresh_token",
|
||||
value=token_data["refresh_token"],
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
max_age=7 * 24 * 3600,
|
||||
path="/api/auth/refresh",
|
||||
)
|
||||
@@ -71,21 +75,31 @@ def login(
|
||||
key="docqube_has_session",
|
||||
value="true",
|
||||
httponly=False,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
max_age=7 * 24 * 3600,
|
||||
path="/",
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
key="csrf_token",
|
||||
value=str(uuid.uuid4()),
|
||||
httponly=False,
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
path="/"
|
||||
)
|
||||
|
||||
if hasattr(request.state, "new_device_id"):
|
||||
response.set_cookie(
|
||||
key="docqube_device_id",
|
||||
value=request.state.new_device_id,
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
max_age=365 * 24 * 3600,
|
||||
path="/",
|
||||
)
|
||||
@@ -97,14 +111,17 @@ def google_login(
|
||||
request: Request, response: Response, payload: GoogleLoginIn, db: Session = Depends(get_db)
|
||||
):
|
||||
token_data = AuthController.google_login(payload, db, request)
|
||||
is_secure = settings.APP_ENV in ["production", "test", "testing"] or request.url.scheme == "https"
|
||||
samesite_mode = "none" if is_secure else "lax"
|
||||
cookie_domain = getattr(settings, "COOKIE_DOMAIN", None) or None
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_access_token",
|
||||
value=token_data["access_token"],
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
max_age=3600,
|
||||
path="/",
|
||||
)
|
||||
@@ -113,9 +130,9 @@ def google_login(
|
||||
key="docqube_refresh_token",
|
||||
value=token_data["refresh_token"],
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
max_age=7 * 24 * 3600,
|
||||
path="/api/auth/refresh",
|
||||
)
|
||||
@@ -124,21 +141,31 @@ def google_login(
|
||||
key="docqube_has_session",
|
||||
value="true",
|
||||
httponly=False,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
max_age=7 * 24 * 3600,
|
||||
path="/",
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
key="csrf_token",
|
||||
value=str(uuid.uuid4()),
|
||||
httponly=False,
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
path="/"
|
||||
)
|
||||
|
||||
if hasattr(request.state, "new_device_id"):
|
||||
response.set_cookie(
|
||||
key="docqube_device_id",
|
||||
value=request.state.new_device_id,
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
max_age=365 * 24 * 3600,
|
||||
path="/",
|
||||
)
|
||||
@@ -225,13 +252,17 @@ def refresh_token(request: Request, response: Response, db: Session = Depends(ge
|
||||
|
||||
new_access_token = create_access_token(payload_access)
|
||||
|
||||
is_secure = settings.APP_ENV in ["production", "test", "testing"] or (request and request.url.scheme == "https")
|
||||
samesite_mode = "none" if is_secure else "lax"
|
||||
cookie_domain = getattr(settings, "COOKIE_DOMAIN", None) or None
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_access_token",
|
||||
value=new_access_token,
|
||||
httponly=True,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
max_age=3600,
|
||||
path="/",
|
||||
)
|
||||
@@ -240,9 +271,9 @@ def refresh_token(request: Request, response: Response, db: Session = Depends(ge
|
||||
key="docqube_has_session",
|
||||
value="true",
|
||||
httponly=False,
|
||||
secure=settings.APP_ENV == "production",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
max_age=7 * 24 * 3600,
|
||||
path="/",
|
||||
)
|
||||
@@ -304,26 +335,37 @@ def logout(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
is_secure = settings.APP_ENV in ["production", "test", "testing"] or (request and request.url.scheme == "https")
|
||||
samesite_mode = "none" if is_secure else "lax"
|
||||
cookie_domain = getattr(settings, "COOKIE_DOMAIN", None) or None
|
||||
|
||||
response.delete_cookie(
|
||||
"docqube_access_token",
|
||||
path="/",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
secure=settings.APP_ENV == "production",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
samesite=samesite_mode,
|
||||
secure=is_secure,
|
||||
domain=cookie_domain,
|
||||
)
|
||||
response.delete_cookie(
|
||||
"docqube_refresh_token",
|
||||
path="/api/auth/refresh",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
secure=settings.APP_ENV == "production",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
samesite=samesite_mode,
|
||||
secure=is_secure,
|
||||
domain=cookie_domain,
|
||||
)
|
||||
response.delete_cookie(
|
||||
"docqube_has_session",
|
||||
path="/",
|
||||
samesite="none" if settings.APP_ENV == "production" else "lax",
|
||||
secure=settings.APP_ENV == "production",
|
||||
domain=settings.COOKIE_DOMAIN if settings.APP_ENV == "production" else None,
|
||||
samesite=samesite_mode,
|
||||
secure=is_secure,
|
||||
domain=cookie_domain,
|
||||
)
|
||||
response.delete_cookie(
|
||||
"csrf_token",
|
||||
path="/",
|
||||
samesite=samesite_mode,
|
||||
secure=is_secure,
|
||||
domain=cookie_domain,
|
||||
)
|
||||
return {"status": "success", "message": "Logged out"}
|
||||
|
||||
|
||||
@@ -54,19 +54,22 @@ def _assert_role_in_callers_tenant(role, current_user: User, db: Session = None,
|
||||
if role.tenant_id == current_user.tenant_id:
|
||||
return
|
||||
|
||||
if allow_system_roles and getattr(role, "is_system", False) and current_user.tenant_id and db:
|
||||
from app.modules.billing.models.plan_model import TenantSubscription, PlanRole
|
||||
sub = db.query(TenantSubscription).filter(
|
||||
TenantSubscription.tenant_id == current_user.tenant_id,
|
||||
TenantSubscription.status == 'active'
|
||||
).order_by(TenantSubscription.created_at.desc()).first()
|
||||
if sub and sub.plan_id:
|
||||
has_role = db.query(PlanRole).filter(
|
||||
PlanRole.plan_id == sub.plan_id,
|
||||
PlanRole.role_id == role.id
|
||||
).first()
|
||||
if has_role:
|
||||
return
|
||||
if allow_system_roles:
|
||||
if getattr(role, "tenant_id", None) is None and getattr(role, "name", "").lower() != "superadmin":
|
||||
return
|
||||
if getattr(role, "is_system", False) and current_user.tenant_id and db:
|
||||
from app.modules.billing.models.plan_model import TenantSubscription, PlanRole
|
||||
sub = db.query(TenantSubscription).filter(
|
||||
TenantSubscription.tenant_id == current_user.tenant_id,
|
||||
TenantSubscription.status == 'active'
|
||||
).order_by(TenantSubscription.created_at.desc()).first()
|
||||
if sub and sub.plan_id:
|
||||
has_role = db.query(PlanRole).filter(
|
||||
PlanRole.plan_id == sub.plan_id,
|
||||
PlanRole.role_id == role.id
|
||||
).first()
|
||||
if has_role:
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
|
||||
|
||||
@@ -63,6 +63,7 @@ class RoleService:
|
||||
query = db.query(Role)
|
||||
if tenant_id is not None:
|
||||
from app.modules.billing.models.plan_model import TenantSubscription, PlanRole
|
||||
from sqlalchemy import and_, func
|
||||
sub = db.query(TenantSubscription).filter(
|
||||
TenantSubscription.tenant_id == tenant_id,
|
||||
TenantSubscription.status == 'active'
|
||||
@@ -72,7 +73,16 @@ class RoleService:
|
||||
plan_role_ids = [pr.role_id for pr in plan_roles]
|
||||
query = query.filter(or_(Role.tenant_id == tenant_id, Role.id.in_(plan_role_ids)))
|
||||
else:
|
||||
query = query.filter(Role.tenant_id == tenant_id)
|
||||
# Include tenant roles AND tenant-accessible default/system roles (excluding superadmin)
|
||||
query = query.filter(
|
||||
or_(
|
||||
Role.tenant_id == tenant_id,
|
||||
and_(
|
||||
Role.tenant_id.is_(None),
|
||||
func.lower(Role.name) != 'superadmin'
|
||||
)
|
||||
)
|
||||
)
|
||||
return query.all()
|
||||
|
||||
@staticmethod
|
||||
@@ -135,6 +145,7 @@ class RoleService:
|
||||
query = db.query(Role)
|
||||
if tenant_id is not None:
|
||||
from app.modules.billing.models.plan_model import TenantSubscription, PlanRole
|
||||
from sqlalchemy import and_, func
|
||||
sub = db.query(TenantSubscription).filter(
|
||||
TenantSubscription.tenant_id == tenant_id,
|
||||
TenantSubscription.status == 'active'
|
||||
@@ -144,7 +155,16 @@ class RoleService:
|
||||
plan_role_ids = [pr.role_id for pr in plan_roles]
|
||||
query = query.filter(or_(Role.tenant_id == tenant_id, Role.id.in_(plan_role_ids)))
|
||||
else:
|
||||
query = query.filter(Role.tenant_id == tenant_id)
|
||||
# Include tenant roles AND tenant-accessible default/system roles (excluding superadmin)
|
||||
query = query.filter(
|
||||
or_(
|
||||
Role.tenant_id == tenant_id,
|
||||
and_(
|
||||
Role.tenant_id.is_(None),
|
||||
func.lower(Role.name) != 'superadmin'
|
||||
)
|
||||
)
|
||||
)
|
||||
if search and search.strip():
|
||||
search_term = search.strip()
|
||||
query = query.filter(Role.name.ilike(f"%{search_term}%"))
|
||||
|
||||
@@ -4,9 +4,12 @@ import time
|
||||
import logging
|
||||
import hmac
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.core.settings import settings
|
||||
from app.core.security import create_access_token, create_refresh_token
|
||||
from app.core.request_body import read_json_body
|
||||
from app.services.saas_service import SaaSService
|
||||
from app.modules.tenant.models.tenant_model import Tenant
|
||||
from app.modules.auth.models.saas_models import SaaSTenantMapping
|
||||
@@ -72,6 +75,13 @@ async def sso_login(
|
||||
"subscription": data.get("subscription"),
|
||||
}
|
||||
|
||||
subscription = data.get("subscription")
|
||||
if user.tenant and subscription:
|
||||
try:
|
||||
_sync_subscription(db, user.tenant, subscription.get("plan_code"), subscription)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not sync subscription for tenant {user.tenant_id}: {e}")
|
||||
|
||||
is_user_superadmin = bool(
|
||||
getattr(user, "is_superadmin", False)
|
||||
or data.get("is_superadmin")
|
||||
@@ -98,14 +108,17 @@ async def sso_login(
|
||||
}
|
||||
)
|
||||
|
||||
is_prod = settings.APP_ENV == "production"
|
||||
is_secure = settings.APP_ENV in ["production", "test", "testing"] or request.url.scheme == "https"
|
||||
samesite_mode = "none" if is_secure else "lax"
|
||||
cookie_domain = getattr(settings, "COOKIE_DOMAIN", None) or None
|
||||
|
||||
response.set_cookie(
|
||||
key="docqube_access_token",
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
secure=is_prod,
|
||||
samesite="lax",
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
path="/",
|
||||
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
@@ -114,12 +127,23 @@ async def sso_login(
|
||||
key="docqube_refresh_token",
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=is_prod,
|
||||
samesite="lax",
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
path="/",
|
||||
max_age=7 * 24 * 3600
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
key="csrf_token",
|
||||
value=str(uuid.uuid4()),
|
||||
httponly=False,
|
||||
secure=is_secure,
|
||||
samesite=samesite_mode,
|
||||
domain=cookie_domain,
|
||||
path="/"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "SSO Login successful",
|
||||
@@ -129,6 +153,8 @@ async def sso_login(
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_superadmin": is_user_superadmin,
|
||||
"role": "superadmin" if is_user_superadmin else (user.role.name if user.role else None),
|
||||
"permissions": permissions
|
||||
}
|
||||
}
|
||||
@@ -281,9 +307,6 @@ async def provision_tenant(request: Request, db: Session = Depends(get_db)):
|
||||
logger.exception("Provisioning webhook failed for event %s", event_type)
|
||||
raise HTTPException(status_code=500, detail=f"Provisioning error: {str(e)}")
|
||||
|
||||
from app.core.security import create_access_token, create_refresh_token
|
||||
from app.core.request_body import read_json_body
|
||||
|
||||
|
||||
def _sync_subscription(db, tenant, plan_code, subscription: dict) -> None:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.infrastructure.storage.local_storage_handler import get_storage_client
|
||||
from app.modules.drive.constants import PRESIGNED_URL_EXPIRY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DriveStorageService:
|
||||
"""
|
||||
Thin wrapper around the Backblaze B2 client.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session = None):
|
||||
self.db = db
|
||||
|
||||
def _get_client_and_bucket(self, tenant_id=None, provider=None):
|
||||
"""Returns (client, quarantine_bucket, clean_bucket)."""
|
||||
return get_storage_client(db=self.db, tenant_id=tenant_id, provider=provider)
|
||||
|
||||
async def upload(self, key: str, file_obj, content_type: str, length: Optional[int] = None, tenant_id=None, provider=None) -> str:
|
||||
"""Upload a file-like object to clean bucket under *key* (scan is done before upload)."""
|
||||
client, quarantine_bucket, clean_bucket = self._get_client_and_bucket(tenant_id, provider)
|
||||
|
||||
use_bucket = clean_bucket or quarantine_bucket
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: client.put_object(
|
||||
Bucket=use_bucket,
|
||||
Key=key,
|
||||
Body=file_obj,
|
||||
ContentType=content_type,
|
||||
length=length
|
||||
),
|
||||
)
|
||||
return "b2"
|
||||
|
||||
async def move_to_clean(self, key: str, quarantine_bucket: str, clean_bucket: str, tenant_id=None, provider=None) -> None:
|
||||
"""Copy from quarantine to clean bucket and delete the quarantine copy."""
|
||||
if quarantine_bucket == clean_bucket:
|
||||
return
|
||||
client, _, _ = self._get_client_and_bucket(tenant_id, provider)
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: client.copy_object(
|
||||
Bucket=clean_bucket,
|
||||
Key=key,
|
||||
CopySource={'Bucket': quarantine_bucket, 'Key': key}
|
||||
),
|
||||
)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: client.delete_object(Bucket=quarantine_bucket, Key=key),
|
||||
)
|
||||
|
||||
async def copy(self, source_key: str, dest_key: str, tenant_id=None, provider=None, bucket: Optional[str] = None) -> None:
|
||||
"""Copy a single object within storage from *source_key* to *dest_key*."""
|
||||
client, quarantine_bucket, clean_bucket = self._get_client_and_bucket(tenant_id, provider)
|
||||
|
||||
use_bucket = bucket or clean_bucket or quarantine_bucket
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: client.copy_object(
|
||||
Bucket=use_bucket,
|
||||
Key=dest_key,
|
||||
CopySource={'Bucket': use_bucket, 'Key': source_key}
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
if not bucket and clean_bucket != quarantine_bucket:
|
||||
fallback_source = quarantine_bucket if use_bucket == clean_bucket else clean_bucket
|
||||
try:
|
||||
logger.info(f"[DriveStorage] Copy failed in {use_bucket}, trying fallback source {fallback_source}")
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: client.copy_object(
|
||||
Bucket=clean_bucket,
|
||||
Key=dest_key,
|
||||
CopySource={'Bucket': fallback_source, 'Key': source_key}
|
||||
),
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
async def delete(self, key: str, tenant_id=None, provider=None) -> None:
|
||||
"""Delete a single object from storage (tries clean then quarantine)."""
|
||||
if not key:
|
||||
return
|
||||
try:
|
||||
client, quarantine_bucket, clean_bucket = self._get_client_and_bucket(tenant_id, provider)
|
||||
if not client:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
for bucket in [clean_bucket, quarantine_bucket]:
|
||||
if not bucket:
|
||||
continue
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda b=bucket, k=key: client.delete_object(Bucket=b, Key=k),
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.debug(f"[DriveStorage] Could not delete {key} from {bucket}: {exc}")
|
||||
logger.debug(f"[DriveStorage] Object {key} not found or already deleted from storage")
|
||||
except Exception as exc:
|
||||
logger.warning(f"[DriveStorage] Error during storage delete for {key}: {exc}")
|
||||
|
||||
def presigned_url(
|
||||
self,
|
||||
key: str,
|
||||
user_id: Optional[int] = None,
|
||||
expiry: int = PRESIGNED_URL_EXPIRY,
|
||||
method: str = "get_object",
|
||||
tenant_id=None,
|
||||
provider=None,
|
||||
bucket: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Return a presigned URL for the given object key."""
|
||||
client, quarantine_bucket, clean_bucket = self._get_client_and_bucket(tenant_id, provider)
|
||||
use_bucket = bucket or clean_bucket
|
||||
return client.generate_presigned_url(
|
||||
method,
|
||||
Params={"Bucket": use_bucket, "Key": key},
|
||||
ExpiresIn=expiry,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
def get_object(self, key: str, tenant_id=None, provider=None, bucket: Optional[str] = None, range_header: Optional[str] = None):
|
||||
"""Return the raw S3 response object (body, metadata). ``range_header``
|
||||
is the raw HTTP Range header value (e.g. "bytes=0-1023"), passed
|
||||
straight through to B2/S3 so callers can serve partial content."""
|
||||
client, quarantine_bucket, clean_bucket = self._get_client_and_bucket(tenant_id, provider)
|
||||
|
||||
if bucket:
|
||||
return client.get_object(Bucket=bucket, Key=key, Range=range_header)
|
||||
|
||||
try:
|
||||
return client.get_object(Bucket=clean_bucket, Key=key, Range=range_header)
|
||||
except Exception:
|
||||
if quarantine_bucket != clean_bucket:
|
||||
return client.get_object(Bucket=quarantine_bucket, Key=key, Range=range_header)
|
||||
raise
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Any
|
||||
from app.modules.storage.services.storage_service import StorageService
|
||||
|
||||
|
||||
class StorageController:
|
||||
@staticmethod
|
||||
def get_storage_usage(user: Any, conn: Session):
|
||||
storage_service = StorageService(conn)
|
||||
return storage_service.get_storage_usage(user)
|
||||
|
||||
@staticmethod
|
||||
async def upload_file(user: Any, file, folder: str, conn: Session):
|
||||
storage_service = StorageService(conn)
|
||||
return await storage_service.upload_file(user, file)
|
||||
|
||||
@staticmethod
|
||||
def list_files(user: Any, limit: int, offset: int, conn: Session):
|
||||
storage_service = StorageService(conn)
|
||||
return storage_service.list_files(user, limit, offset)
|
||||
|
||||
@staticmethod
|
||||
def delete_file(user: Any, file_id: int, conn: Session):
|
||||
storage_service = StorageService(conn)
|
||||
return storage_service.delete_file(user, file_id)
|
||||
@@ -0,0 +1,49 @@
|
||||
import uuid
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
BigInteger,
|
||||
TIMESTAMP,
|
||||
ForeignKey,
|
||||
Boolean,
|
||||
)
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
from app.db.database import Base
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class UserFile(Base):
|
||||
__tablename__ = "user_files"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False, index=True
|
||||
)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
filename = Column(String, nullable=False)
|
||||
storage_provider = Column(String(50), nullable=False, default="b2")
|
||||
s3_key = Column(String, nullable=False)
|
||||
size = Column(BigInteger, default=0)
|
||||
content_type = Column(String, nullable=True)
|
||||
status = Column(String, default="uploaded")
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
deleted_at = Column(TIMESTAMP, nullable=True)
|
||||
created_at = Column(TIMESTAMP, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
user = relationship("User", back_populates="user_files")
|
||||
|
||||
|
||||
class UserStorageUsage(Base):
|
||||
__tablename__ = "user_storage_usage"
|
||||
user_id = Column(Integer, ForeignKey("users.id"), primary_key=True, nullable=False)
|
||||
total_bytes_used = Column(BigInteger, default=0)
|
||||
total_files_count = Column(Integer, default=0)
|
||||
max_bytes_quota = Column(BigInteger, default=1073741824)
|
||||
last_updated = Column(
|
||||
TIMESTAMP,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="storage_usage")
|
||||
@@ -0,0 +1,287 @@
|
||||
import uuid
|
||||
from typing import List, Optional, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, case, or_
|
||||
from app.modules.storage.models.storage_model import UserFile, UserStorageUsage
|
||||
from app.modules.drive.models.drive_model import DriveFile
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.tenant.models.tenant_model import Tenant
|
||||
|
||||
|
||||
class StorageRepository:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_user_file_usages(self, user_id: int) -> Any:
|
||||
return (
|
||||
self.db.query(
|
||||
func.coalesce(func.sum(UserFile.size), 0).label("total"),
|
||||
func.count(UserFile.id).label("count"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(UserFile.content_type == "application/pdf", UserFile.size),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("pdf_size"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
UserFile.content_type
|
||||
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
UserFile.size,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("docx_size"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(UserFile.content_type == "text/html", UserFile.size),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("html_size"),
|
||||
)
|
||||
.filter(
|
||||
UserFile.user_id == user_id,
|
||||
or_(UserFile.is_deleted == False, UserFile.is_deleted == None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_drive_file_usages(self, user_id: int) -> Any:
|
||||
return (
|
||||
self.db.query(
|
||||
func.coalesce(func.sum(DriveFile.size), 0).label("total"),
|
||||
func.count(DriveFile.id).label("count"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(DriveFile.mime_type == "application/pdf", DriveFile.size),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("pdf_size"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
DriveFile.mime_type
|
||||
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
DriveFile.size,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("docx_size"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(DriveFile.mime_type == "text/html", DriveFile.size),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("html_size"),
|
||||
)
|
||||
.filter(DriveFile.owner_id == user_id, DriveFile.is_trashed == False)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_tenant_user_file_usages(self, tenant_id: uuid.UUID) -> Any:
|
||||
return (
|
||||
self.db.query(
|
||||
func.coalesce(func.sum(UserFile.size), 0).label("total"),
|
||||
func.count(UserFile.id).label("count"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(UserFile.content_type == "application/pdf", UserFile.size),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("pdf_size"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
UserFile.content_type
|
||||
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
UserFile.size,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("docx_size"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(UserFile.content_type == "text/html", UserFile.size),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("html_size"),
|
||||
)
|
||||
.filter(
|
||||
UserFile.tenant_id == tenant_id,
|
||||
or_(UserFile.is_deleted == False, UserFile.is_deleted == None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_tenant_drive_file_usages(self, tenant_id: uuid.UUID) -> Any:
|
||||
return (
|
||||
self.db.query(
|
||||
func.coalesce(func.sum(DriveFile.size), 0).label("total"),
|
||||
func.count(DriveFile.id).label("count"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(DriveFile.mime_type == "application/pdf", DriveFile.size),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("pdf_size"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
DriveFile.mime_type
|
||||
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
DriveFile.size,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("docx_size"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(DriveFile.mime_type == "text/html", DriveFile.size),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("html_size"),
|
||||
)
|
||||
.filter(DriveFile.tenant_id == tenant_id, DriveFile.is_trashed == False)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_user(self, user_id: int) -> Optional[User]:
|
||||
return self.db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
def get_tenant(self, tenant_id: uuid.UUID) -> Optional[Tenant]:
|
||||
return self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
|
||||
def create_user_file(self, user_file: UserFile) -> UserFile:
|
||||
self.db.add(user_file)
|
||||
self.db.flush()
|
||||
self.db.refresh(user_file)
|
||||
return user_file
|
||||
|
||||
def list_user_files(
|
||||
self, user_id: int, tenant_id: uuid.UUID, limit: int, offset: int
|
||||
) -> List[UserFile]:
|
||||
return (
|
||||
self.db.query(UserFile)
|
||||
.filter(
|
||||
UserFile.user_id == user_id,
|
||||
UserFile.tenant_id == tenant_id,
|
||||
or_(UserFile.is_deleted == False, UserFile.is_deleted == None),
|
||||
)
|
||||
.order_by(UserFile.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
)
|
||||
|
||||
def count_user_files(self, user_id: int, tenant_id: uuid.UUID) -> int:
|
||||
return (
|
||||
self.db.query(func.count(UserFile.id))
|
||||
.filter(
|
||||
UserFile.user_id == user_id,
|
||||
UserFile.tenant_id == tenant_id,
|
||||
or_(UserFile.is_deleted == False, UserFile.is_deleted == None),
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
def get_user_file_by_id(
|
||||
self, user_id: int, file_id: int, tenant_id: uuid.UUID
|
||||
) -> Optional[UserFile]:
|
||||
return (
|
||||
self.db.query(UserFile)
|
||||
.filter(
|
||||
UserFile.id == file_id,
|
||||
UserFile.user_id == user_id,
|
||||
UserFile.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_or_create_storage_usage(self, user_id: int) -> UserStorageUsage:
|
||||
"""
|
||||
Fetch the user's storage-usage row, creating it if it is missing.
|
||||
|
||||
The obvious version — SELECT, then INSERT if absent — is a race, and not
|
||||
a theoretical one: the B6.1 load harness hit it within seconds at ten
|
||||
concurrent users. Two requests both find no row, both insert, and the
|
||||
loser dies with `duplicate key value violates unique constraint
|
||||
"user_storage_usage_pkey"` — a 500 on a read-only endpoint, which is the
|
||||
kind of failure that only ever appears in production.
|
||||
|
||||
An `ON CONFLICT DO NOTHING` insert inside a savepoint lets the database
|
||||
arbitrate instead. The savepoint matters: without it a conflict would
|
||||
leave the surrounding transaction unusable even though nothing is wrong.
|
||||
"""
|
||||
usage = (
|
||||
self.db.query(UserStorageUsage)
|
||||
.filter(UserStorageUsage.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if usage:
|
||||
return usage
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
with self.db.begin_nested():
|
||||
self.db.execute(
|
||||
pg_insert(UserStorageUsage.__table__)
|
||||
.values(user_id=user_id)
|
||||
.on_conflict_do_nothing(index_elements=["user_id"])
|
||||
)
|
||||
|
||||
return (
|
||||
self.db.query(UserStorageUsage)
|
||||
.filter(UserStorageUsage.user_id == user_id)
|
||||
.one()
|
||||
)
|
||||
|
||||
def update_usage_stats(self, user_id: int, total_bytes: int, file_count: int):
|
||||
usage = self.get_or_create_storage_usage(user_id)
|
||||
usage.total_bytes_used = total_bytes
|
||||
usage.total_files_count = file_count
|
||||
usage.last_updated = func.now()
|
||||
self.db.flush()
|
||||
|
||||
def commit(self):
|
||||
self.db.flush()
|
||||
|
||||
def flush(self):
|
||||
self.db.flush()
|
||||
@@ -0,0 +1,45 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
|
||||
from app.db.database import get_db
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.storage.controllers.storage_controller import StorageController
|
||||
from app.modules.storage.schemas.storage_schema import (
|
||||
StorageFilesResponse,
|
||||
StorageUsageResponse,
|
||||
)
|
||||
from app.core.schemas import SuccessOut
|
||||
|
||||
router = APIRouter(prefix="/storage", tags=["Storage"])
|
||||
|
||||
|
||||
@router.get("/usage", response_model=StorageUsageResponse)
|
||||
def get_usage(conn=Depends(get_db), user: User = Depends(get_current_user)):
|
||||
usage = StorageController.get_storage_usage(user, conn)
|
||||
return {"user_id": user.id, "tenant_id": user.tenant_id, "usage": usage}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
folder: str = Form(""),
|
||||
conn=Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return await StorageController.upload_file(user, file, folder, conn)
|
||||
|
||||
|
||||
@router.get("/files", response_model=StorageFilesResponse)
|
||||
def list_files(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
conn=Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return StorageController.list_files(user, limit, offset, conn)
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}", response_model=SuccessOut)
|
||||
def delete_file(
|
||||
file_id: int, conn=Depends(get_db), user: User = Depends(get_current_user)
|
||||
):
|
||||
return StorageController.delete_file(user, file_id, conn)
|
||||
@@ -0,0 +1,109 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class UserFileOut(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
file_name: str
|
||||
s3_key: str
|
||||
file_size: int
|
||||
mime_type: Optional[str]
|
||||
is_deleted: bool
|
||||
deleted_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class StorageUsageOut(BaseModel):
|
||||
total_bytes: int
|
||||
file_count: int
|
||||
last_updated: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class StorageStatsOut(BaseModel):
|
||||
user_id: int
|
||||
total_bytes: int
|
||||
file_count: int
|
||||
quota_bytes: int
|
||||
usage_percentage: float
|
||||
|
||||
|
||||
class StorageBreakdown(BaseModel):
|
||||
pdf: int = 0
|
||||
docx: int = 0
|
||||
html: int = 0
|
||||
other: int = 0
|
||||
|
||||
|
||||
class StorageAlerts(BaseModel):
|
||||
severity: Optional[str] = None
|
||||
ring_color: Optional[str] = None
|
||||
banner: Optional[str] = None
|
||||
center_message: Optional[str] = None
|
||||
current_threshold: Optional[float] = None
|
||||
|
||||
|
||||
class UserStorageStats(BaseModel):
|
||||
used_bytes: int = 0
|
||||
used_formatted: Optional[str] = None
|
||||
file_count: int = 0
|
||||
percent: float = 0.0
|
||||
breakdown: StorageBreakdown = StorageBreakdown()
|
||||
|
||||
|
||||
class StorageUsageDetail(BaseModel):
|
||||
"""The `usage` / `storage` object, shared by both endpoints."""
|
||||
|
||||
scope: Optional[str] = None
|
||||
tenant_id: Optional[str] = None
|
||||
used_bytes: int = 0
|
||||
used_formatted: Optional[str] = None
|
||||
total_bytes: int = 0
|
||||
total_formatted: Optional[str] = None
|
||||
quota_bytes: int = 0
|
||||
quota_formatted: Optional[str] = None
|
||||
remaining_bytes: int = 0
|
||||
remaining_formatted: Optional[str] = None
|
||||
file_count: int = 0
|
||||
percent: float = 0.0
|
||||
usage_percentage: float = 0.0
|
||||
breakdown: StorageBreakdown = StorageBreakdown()
|
||||
alerts: StorageAlerts = StorageAlerts()
|
||||
user_stats: Optional[UserStorageStats] = None
|
||||
|
||||
|
||||
class StorageUsageResponse(BaseModel):
|
||||
user_id: int
|
||||
tenant_id: Optional[UUID] = None
|
||||
usage: StorageUsageDetail
|
||||
|
||||
|
||||
class StorageFileSummary(BaseModel):
|
||||
"""One row of `GET /api/storage/files`, as built by StorageService."""
|
||||
|
||||
id: int
|
||||
name: Optional[str] = None
|
||||
size: int = 0
|
||||
size_formatted: Optional[str] = None
|
||||
mime_type: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class Pagination(BaseModel):
|
||||
limit: int
|
||||
offset: int
|
||||
total_count: int
|
||||
|
||||
|
||||
class StorageFilesResponse(BaseModel):
|
||||
files: List[StorageFileSummary] = []
|
||||
pagination: Pagination
|
||||
storage: StorageUsageDetail
|
||||
@@ -0,0 +1,27 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.documents.models.document_model import Project
|
||||
from app.modules.drive.models.drive_model import DriveFile
|
||||
from app.middleware.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/stats", tags=["Stats"])
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def get_dashboard_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get dashboard statistics for the logged-in user.
|
||||
"""
|
||||
project_count = db.query(Project).filter(Project.user_id == current_user.id).count()
|
||||
file_count = db.query(DriveFile).filter(DriveFile.owner_id == current_user.id).count()
|
||||
|
||||
return {
|
||||
"projects_count": project_count,
|
||||
"files_count": file_count,
|
||||
"storage_used": 0,
|
||||
"subscription": current_user.subscription
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.mail import send_email
|
||||
from app.db.redis import redis_cache
|
||||
from app.infrastructure.storage.local_storage_handler import format_bytes
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.notifications.services.notification_service import NotificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StorageAlertService:
|
||||
THRESHOLDS = (50, 75, 90, 100)
|
||||
REDIS_KEY_PREFIX = "storage_alert_thresholds"
|
||||
EMAIL_SUPPRESSION_HOURS = 48
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.notifications = NotificationService(db)
|
||||
|
||||
def process_usage(self, user: User, usage: Dict[str, Any]) -> None:
|
||||
if not user:
|
||||
return
|
||||
|
||||
if usage.get("quota_bytes") == -1:
|
||||
self._clear_threshold_state(user.id)
|
||||
return
|
||||
|
||||
percent = float(usage.get("usage_percentage") or 0)
|
||||
active_thresholds = self._get_active_thresholds(user.id)
|
||||
thresholds_to_activate: list[str] = []
|
||||
thresholds_to_clear: list[str] = []
|
||||
|
||||
for threshold in self.THRESHOLDS:
|
||||
key = str(threshold)
|
||||
if percent >= threshold:
|
||||
if key not in active_thresholds:
|
||||
if not self._dispatch_alert(user, usage, threshold):
|
||||
return
|
||||
thresholds_to_activate.append(key)
|
||||
elif key in active_thresholds:
|
||||
thresholds_to_clear.append(key)
|
||||
|
||||
for key in thresholds_to_activate:
|
||||
self._mark_threshold_active(user.id, key)
|
||||
|
||||
for key in thresholds_to_clear:
|
||||
self._clear_threshold(user.id, key)
|
||||
|
||||
def build_alert_context(self, user: User, usage: Dict[str, Any]) -> Dict[str, Any]:
|
||||
percent = float(usage.get("usage_percentage") or 0)
|
||||
current_threshold = self._current_threshold(percent)
|
||||
|
||||
return {
|
||||
"current_threshold": current_threshold,
|
||||
"severity": self._severity(percent),
|
||||
"ring_color": self._ring_color(percent),
|
||||
"center_message": self._center_message(percent),
|
||||
"banner": self._banner_payload(percent, current_threshold),
|
||||
}
|
||||
|
||||
def _dispatch_alert(self, user: User, usage: Dict[str, Any], threshold: int) -> bool:
|
||||
content = self._alert_content(user, usage, threshold)
|
||||
try:
|
||||
self.notifications.notify_storage_alert(
|
||||
user_id=user.id,
|
||||
title=content["in_app_title"],
|
||||
message=content["in_app_message"],
|
||||
tenant_id=user.tenant_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to create storage alert notification for user %s at %s%%: %s",
|
||||
user.id,
|
||||
threshold,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if user.email and self._should_send_email(user):
|
||||
try:
|
||||
send_email(
|
||||
subject=content["email_subject"],
|
||||
recipient=user.email,
|
||||
template_name=content["template_name"],
|
||||
template_context=content["template_context"],
|
||||
db=self.db,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to send storage alert email to %s for %s%% threshold: %s",
|
||||
user.email,
|
||||
threshold,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def _alert_content(
|
||||
self, user: User, usage: Dict[str, Any], threshold: int
|
||||
) -> Dict[str, Any]:
|
||||
used_formatted = usage.get("used_formatted") or format_bytes(
|
||||
int(usage.get("used_bytes") or 0)
|
||||
)
|
||||
quota_formatted = usage.get("quota_formatted") or format_bytes(
|
||||
int(usage.get("quota_bytes") or 0)
|
||||
)
|
||||
config = {
|
||||
50: {
|
||||
"subject": "Storage usage is at 50%",
|
||||
"title": "Storage is halfway used",
|
||||
"message": "You have used 50% of your storage quota.",
|
||||
"template": "storage_alert_50.html",
|
||||
},
|
||||
75: {
|
||||
"subject": "Storage usage is at 75%",
|
||||
"title": "Storage usage is climbing",
|
||||
"message": "You have used 75% of your storage quota.",
|
||||
"template": "storage_alert_75.html",
|
||||
},
|
||||
90: {
|
||||
"subject": "Storage usage is at 90%",
|
||||
"title": "Storage is nearly full",
|
||||
"message": "You have used 90% of your storage quota.",
|
||||
"template": "storage_alert_90.html",
|
||||
},
|
||||
100: {
|
||||
"subject": "Storage usage has reached 100%",
|
||||
"title": "Storage has reached its quota",
|
||||
"message": "Your storage usage has reached 100% of the configured quota.",
|
||||
"template": "storage_alert_100.html",
|
||||
},
|
||||
}[threshold]
|
||||
|
||||
return {
|
||||
"email_subject": config["subject"],
|
||||
"in_app_title": config["title"],
|
||||
"in_app_message": config["message"],
|
||||
"template_name": config["template"],
|
||||
"template_context": {
|
||||
"user_name": user.name or user.email,
|
||||
"threshold": threshold,
|
||||
"used_formatted": used_formatted,
|
||||
"quota_formatted": quota_formatted,
|
||||
"percent_used": round(float(usage.get("usage_percentage") or 0), 1),
|
||||
"year": datetime.now(timezone.utc).year,
|
||||
},
|
||||
}
|
||||
|
||||
def _redis_key(self, user_id: int) -> str:
|
||||
return f"{self.REDIS_KEY_PREFIX}:{user_id}"
|
||||
|
||||
def _get_active_thresholds(self, user_id: int) -> set[str]:
|
||||
try:
|
||||
state = redis_cache.hgetall(self._redis_key(user_id))
|
||||
if not state:
|
||||
return set()
|
||||
active = set()
|
||||
for k, v in state.items():
|
||||
k_str = k.decode("utf-8") if isinstance(k, bytes) else str(k)
|
||||
v_str = v.decode("utf-8") if isinstance(v, bytes) else str(v)
|
||||
if v_str == "1":
|
||||
active.add(k_str)
|
||||
return active
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to load storage alert threshold state for user %s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return set()
|
||||
|
||||
def _mark_threshold_active(self, user_id: int, threshold_key: str) -> None:
|
||||
try:
|
||||
redis_cache.hset(self._redis_key(user_id), threshold_key, "1")
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to persist storage alert threshold %s for user %s: %s",
|
||||
threshold_key,
|
||||
user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _clear_threshold(self, user_id: int, threshold_key: str) -> None:
|
||||
try:
|
||||
redis_cache.hdel(self._redis_key(user_id), threshold_key)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to clear storage alert threshold %s for user %s: %s",
|
||||
threshold_key,
|
||||
user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _clear_threshold_state(self, user_id: int) -> None:
|
||||
try:
|
||||
redis_cache.delete(self._redis_key(user_id))
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to clear storage alert state for user %s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _should_send_email(self, user: User) -> bool:
|
||||
created_at = getattr(user, "created_at", None)
|
||||
if not created_at:
|
||||
return True
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=self.EMAIL_SUPPRESSION_HOURS)
|
||||
return created_at <= cutoff
|
||||
|
||||
def _current_threshold(self, percent: float) -> Optional[int]:
|
||||
crossed = [threshold for threshold in self.THRESHOLDS if percent >= threshold]
|
||||
return max(crossed) if crossed else None
|
||||
|
||||
def _severity(self, percent: float) -> str:
|
||||
if percent >= 90:
|
||||
return "critical"
|
||||
if percent >= 75:
|
||||
return "warning"
|
||||
return "normal"
|
||||
|
||||
def _ring_color(self, percent: float) -> str:
|
||||
if percent >= 90:
|
||||
return "red"
|
||||
if percent >= 75:
|
||||
return "amber"
|
||||
return "blue"
|
||||
|
||||
def _center_message(self, percent: float) -> Optional[str]:
|
||||
threshold = self._current_threshold(percent)
|
||||
messages = {
|
||||
75: "Storage needs attention",
|
||||
90: "Storage is nearly full",
|
||||
100: "Storage is at quota",
|
||||
}
|
||||
return messages.get(threshold)
|
||||
|
||||
def _banner_payload(
|
||||
self, percent: float, current_threshold: Optional[int]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if percent < 90 or not current_threshold:
|
||||
return None
|
||||
threshold = current_threshold
|
||||
banner_copy = {
|
||||
90: {
|
||||
"title": "Storage is nearly full",
|
||||
"message": "You have used 90% of your storage quota.",
|
||||
},
|
||||
100: {
|
||||
"title": "Storage has reached its quota",
|
||||
"message": "Your storage usage has reached 100% of the configured quota.",
|
||||
},
|
||||
}.get(threshold)
|
||||
if not banner_copy:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": threshold,
|
||||
"threshold": threshold,
|
||||
"title": banner_copy["title"],
|
||||
"message": banner_copy["message"],
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.modules.auth.models.user_model import User
|
||||
from app.modules.storage.models.storage_model import UserFile
|
||||
from app.modules.storage.repositories.storage_repository import StorageRepository
|
||||
from app.infrastructure.storage.local_storage_handler import (
|
||||
get_storage_client,
|
||||
format_bytes,
|
||||
generate_storage_key,
|
||||
)
|
||||
from app.modules.storage.services.storage_alert_service import StorageAlertService
|
||||
from app.core.clamav import (
|
||||
ClamAVError,
|
||||
MalwareDetectedError,
|
||||
scan_file_for_malware,
|
||||
)
|
||||
from app.core.transactions import compensate_on_rollback, defer_until_commit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
DEFAULT_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024
|
||||
|
||||
|
||||
def _best_effort_delete(client, buckets, key: str) -> None:
|
||||
"""Remove `key` from the first bucket that has it. Never raises."""
|
||||
for bucket in buckets:
|
||||
try:
|
||||
client.delete_object(Bucket=bucket, Key=key)
|
||||
logger.info("Removed %s from [%s]", key, bucket)
|
||||
return
|
||||
except Exception: # noqa: BLE001 - try the next bucket
|
||||
continue
|
||||
logger.warning("Could not remove %s from any bucket", key)
|
||||
|
||||
|
||||
class StorageService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.storage_repo = StorageRepository(db)
|
||||
|
||||
@staticmethod
|
||||
def _build_usage_aggregates(res_total: Any, res_drive: Any) -> Dict[str, int]:
|
||||
total_bytes = int((res_total.total or 0) + (res_drive.total or 0))
|
||||
file_count = int((res_total.count or 0) + (res_drive.count or 0))
|
||||
|
||||
pdf_bytes = int((res_total.pdf_size or 0) + (res_drive.pdf_size or 0))
|
||||
docx_bytes = int((res_total.docx_size or 0) + (res_drive.docx_size or 0))
|
||||
html_bytes = int((res_total.html_size or 0) + (res_drive.html_size or 0))
|
||||
other_bytes = max(0, total_bytes - (pdf_bytes + docx_bytes + html_bytes))
|
||||
|
||||
return {
|
||||
"total_bytes": total_bytes,
|
||||
"file_count": file_count,
|
||||
"pdf_bytes": pdf_bytes,
|
||||
"docx_bytes": docx_bytes,
|
||||
"html_bytes": html_bytes,
|
||||
"other_bytes": other_bytes,
|
||||
}
|
||||
|
||||
def _resolve_quota(self, user: User) -> tuple[int, str]:
|
||||
"""
|
||||
Same number the drive path enforces, from the same place.
|
||||
|
||||
These two resolved the quota independently before, which is how they
|
||||
would eventually have disagreed — one reading the column, the other
|
||||
reading something else, with nothing to notice.
|
||||
"""
|
||||
if user.tenant_id:
|
||||
from app.modules.billing.services.entitlement_service import (
|
||||
EntitlementService,
|
||||
)
|
||||
|
||||
tenant = self.storage_repo.get_tenant(user.tenant_id)
|
||||
if tenant:
|
||||
return (
|
||||
int(EntitlementService(self.db).limit(tenant, "storage_bytes")),
|
||||
"tenant",
|
||||
)
|
||||
return DEFAULT_STORAGE_QUOTA_BYTES, "tenant"
|
||||
|
||||
usage_record = self.storage_repo.get_or_create_storage_usage(user.id)
|
||||
quota = usage_record.max_bytes_quota or DEFAULT_STORAGE_QUOTA_BYTES
|
||||
return int(quota), "user"
|
||||
|
||||
def _build_usage_response(
|
||||
self,
|
||||
user: User,
|
||||
own_aggregates: Dict[str, int],
|
||||
aggregates: Dict[str, int],
|
||||
limit: int,
|
||||
scope: str,
|
||||
) -> Dict[str, Any]:
|
||||
total_bytes = aggregates["total_bytes"]
|
||||
file_count = aggregates["file_count"]
|
||||
pdf_bytes = aggregates["pdf_bytes"]
|
||||
docx_bytes = aggregates["docx_bytes"]
|
||||
html_bytes = aggregates["html_bytes"]
|
||||
other_bytes = aggregates["other_bytes"]
|
||||
|
||||
if limit == -1:
|
||||
remaining_bytes = 0
|
||||
percent = 0
|
||||
quota_formatted = "Unlimited"
|
||||
remaining_formatted = "Unlimited"
|
||||
else:
|
||||
remaining_bytes = max(0, limit - total_bytes)
|
||||
percent = (total_bytes / limit) * 100 if limit > 0 else 0
|
||||
quota_formatted = format_bytes(limit)
|
||||
remaining_formatted = format_bytes(remaining_bytes)
|
||||
|
||||
user_used = own_aggregates["total_bytes"]
|
||||
user_percent = (user_used / limit * 100) if limit > 0 else 0
|
||||
|
||||
usage = {
|
||||
"total_bytes": total_bytes,
|
||||
"total_formatted": format_bytes(total_bytes),
|
||||
"used_bytes": total_bytes,
|
||||
"used_formatted": format_bytes(total_bytes),
|
||||
"file_count": file_count,
|
||||
"quota_bytes": limit,
|
||||
"quota_formatted": quota_formatted,
|
||||
"remaining_bytes": remaining_bytes,
|
||||
"remaining_formatted": remaining_formatted,
|
||||
"percent": percent,
|
||||
"usage_percentage": percent,
|
||||
"scope": scope,
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"breakdown": {
|
||||
"pdf": pdf_bytes,
|
||||
"docx": docx_bytes,
|
||||
"html": html_bytes,
|
||||
"other": other_bytes,
|
||||
},
|
||||
"user_stats": {
|
||||
"used_bytes": user_used,
|
||||
"used_formatted": format_bytes(user_used),
|
||||
"percent": user_percent,
|
||||
"file_count": own_aggregates["file_count"],
|
||||
"breakdown": {
|
||||
"pdf": own_aggregates["pdf_bytes"],
|
||||
"docx": own_aggregates["docx_bytes"],
|
||||
"html": own_aggregates["html_bytes"],
|
||||
"other": own_aggregates["other_bytes"],
|
||||
},
|
||||
}
|
||||
if user.tenant_id
|
||||
else None,
|
||||
}
|
||||
usage["alerts"] = self._build_alert_context(user, usage)
|
||||
return usage
|
||||
|
||||
def process_storage_alerts(self, user: User) -> Dict[str, Any]:
|
||||
usage = self.get_storage_usage(user)
|
||||
alert_service = StorageAlertService(self.db)
|
||||
|
||||
try:
|
||||
alert_service.process_usage(user, usage)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to process storage alerts for user %s: %s",
|
||||
user.id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
usage["alerts"] = self._build_alert_context(user, usage, alert_service)
|
||||
return usage
|
||||
|
||||
def _build_alert_context(
|
||||
self,
|
||||
user: User,
|
||||
usage: Dict[str, Any],
|
||||
alert_service: Optional[StorageAlertService] = None,
|
||||
) -> Dict[str, Any]:
|
||||
alert_service = alert_service or StorageAlertService(self.db)
|
||||
|
||||
try:
|
||||
return alert_service.build_alert_context(user, usage)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to build storage alert context for user %s: %s",
|
||||
user.id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return {
|
||||
"current_threshold": None,
|
||||
"severity": "normal",
|
||||
"ring_color": "blue",
|
||||
"center_message": None,
|
||||
"banner": None,
|
||||
"history": [],
|
||||
}
|
||||
|
||||
def _dispatch_storage_alerts(self, user: User) -> Dict[str, Any]:
|
||||
usage = self.get_storage_usage(user)
|
||||
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
usage = self.process_storage_alerts(user)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Storage alert dispatch failed for user %s: %s",
|
||||
user.id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return usage
|
||||
|
||||
@staticmethod
|
||||
def invalidate_storage_cache(user_id: int):
|
||||
try:
|
||||
from app.db.redis import redis_cache
|
||||
from app.modules.drive.constants import CACHE_USER_STORAGE
|
||||
redis_cache.delete(CACHE_USER_STORAGE.format(user_id=user_id))
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to invalidate storage cache for user {user_id}: {e}")
|
||||
|
||||
def get_storage_usage(self, user: User) -> Dict[str, Any]:
|
||||
# 1. Try Redis cache lookup
|
||||
cache_key = None
|
||||
try:
|
||||
from app.db.redis import redis_cache
|
||||
from app.modules.drive.constants import CACHE_USER_STORAGE, CACHE_STORAGE_TTL_SECONDS
|
||||
cache_key = CACHE_USER_STORAGE.format(user_id=user.id)
|
||||
cached = redis_cache.get(cache_key)
|
||||
if cached:
|
||||
import json
|
||||
return json.loads(cached)
|
||||
except Exception as e:
|
||||
logger.debug(f"Redis cache lookup failed for storage usage: {e}")
|
||||
|
||||
# 2. Database calculation on miss
|
||||
own_storage_usage = self.storage_repo.get_user_file_usages(user.id)
|
||||
own_drive_usage = self.storage_repo.get_drive_file_usages(user.id)
|
||||
own_aggregates = self._build_usage_aggregates(own_storage_usage, own_drive_usage)
|
||||
self.storage_repo.update_usage_stats(
|
||||
user.id, own_aggregates["total_bytes"], own_aggregates["file_count"]
|
||||
)
|
||||
|
||||
if user.tenant_id:
|
||||
res_total = self.storage_repo.get_tenant_user_file_usages(user.tenant_id)
|
||||
res_drive = self.storage_repo.get_tenant_drive_file_usages(user.tenant_id)
|
||||
else:
|
||||
res_total = own_storage_usage
|
||||
res_drive = own_drive_usage
|
||||
|
||||
aggregates = self._build_usage_aggregates(res_total, res_drive)
|
||||
limit, scope = self._resolve_quota(user)
|
||||
result = self._build_usage_response(user, own_aggregates, aggregates, limit, scope)
|
||||
|
||||
# 3. Store in Redis
|
||||
if cache_key:
|
||||
try:
|
||||
import json
|
||||
redis_cache.set(cache_key, json.dumps(result), ex=CACHE_STORAGE_TTL_SECONDS)
|
||||
except Exception as e:
|
||||
logger.debug(f"Redis cache set failed for storage usage: {e}")
|
||||
|
||||
return result
|
||||
|
||||
async def upload_file(self, user: User, file: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Upload a file using the quarantine → scan → clean flow:
|
||||
1. Upload to quarantine bucket.
|
||||
2. Virus scan.
|
||||
3. On clean: copy to clean bucket, delete from quarantine.
|
||||
4. On malware: delete from quarantine, raise HTTP 400.
|
||||
"""
|
||||
try:
|
||||
file.file.seek(0, 2)
|
||||
size = file.file.tell()
|
||||
file.file.seek(0)
|
||||
except Exception:
|
||||
logger.warning("Could not determine size via seek for upload. Reading into memory as fallback.")
|
||||
content = await file.read()
|
||||
size = len(content)
|
||||
import io
|
||||
file_body = io.BytesIO(content)
|
||||
else:
|
||||
file_body = file.file
|
||||
|
||||
s3_key = generate_storage_key(
|
||||
str(user.tenant_id) if user.tenant_id else "", user.id, file.filename
|
||||
)
|
||||
mime = file.content_type or "application/octet-stream"
|
||||
|
||||
s3, quarantine_bucket, clean_bucket = get_storage_client(db=self.db, tenant_id=user.tenant_id)
|
||||
provider = "b2"
|
||||
|
||||
try:
|
||||
s3.put_object(
|
||||
Bucket=quarantine_bucket,
|
||||
Key=s3_key,
|
||||
Body=file_body,
|
||||
ContentType=mime,
|
||||
Metadata={"user_id": str(user.id)},
|
||||
length=size,
|
||||
)
|
||||
logger.info(f"Uploaded {s3_key} to quarantine bucket [{quarantine_bucket}]")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Storage Upload Error (quarantine): {e}")
|
||||
raise HTTPException(status_code=500, detail="Upload Failed")
|
||||
|
||||
try:
|
||||
if hasattr(file_body, 'seek'):
|
||||
file_body.seek(0)
|
||||
await asyncio.to_thread(scan_file_for_malware, file_body)
|
||||
except MalwareDetectedError as exc:
|
||||
try:
|
||||
s3.delete_object(Bucket=quarantine_bucket, Key=s3_key)
|
||||
except Exception:
|
||||
logger.error(f"Failed to clean up quarantine file {s3_key} after malware detection")
|
||||
raise HTTPException(status_code=400, detail=f"Virus detected: {exc.virus_name}")
|
||||
except ClamAVError as exc:
|
||||
logger.error(f"ClamAV scan failed for user {user.id}: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Malware scan service unavailable. Please try again.",
|
||||
)
|
||||
|
||||
if quarantine_bucket != clean_bucket:
|
||||
try:
|
||||
s3.copy_object(
|
||||
Bucket=clean_bucket,
|
||||
Key=s3_key,
|
||||
CopySource={"Bucket": quarantine_bucket, "Key": s3_key},
|
||||
)
|
||||
s3.delete_object(Bucket=quarantine_bucket, Key=s3_key)
|
||||
logger.info(f"Moved {s3_key} from quarantine [{quarantine_bucket}] to clean [{clean_bucket}]")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to move {s3_key} from quarantine to clean bucket: {e}. File stays in quarantine."
|
||||
)
|
||||
|
||||
compensate_on_rollback(
|
||||
self.db,
|
||||
lambda: _best_effort_delete(s3, [clean_bucket, quarantine_bucket], s3_key),
|
||||
)
|
||||
|
||||
user_file = UserFile(
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
filename=file.filename,
|
||||
s3_key=s3_key,
|
||||
storage_provider=provider,
|
||||
size=size,
|
||||
content_type=mime,
|
||||
created_at=func.now(),
|
||||
)
|
||||
user_file = self.storage_repo.create_user_file(user_file)
|
||||
try:
|
||||
self.db.commit()
|
||||
self.invalidate_storage_cache(user.id)
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
usage = self._dispatch_storage_alerts(user)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "File uploaded successfully",
|
||||
"file": {
|
||||
"id": user_file.id,
|
||||
"name": user_file.filename,
|
||||
"size": user_file.size,
|
||||
"size_formatted": format_bytes(user_file.size),
|
||||
"mime_type": user_file.content_type,
|
||||
"created_at": (
|
||||
user_file.created_at.isoformat() if user_file.created_at else None
|
||||
),
|
||||
},
|
||||
"storage": usage,
|
||||
}
|
||||
|
||||
def list_files(self, user: User, limit: int, offset: int) -> Dict[str, Any]:
|
||||
rows = self.storage_repo.list_user_files(user.id, user.tenant_id, limit, offset)
|
||||
files = [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.filename,
|
||||
"size": r.size,
|
||||
"size_formatted": format_bytes(r.size),
|
||||
"mime_type": r.content_type,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
total_count = self.storage_repo.count_user_files(user.id, user.tenant_id)
|
||||
usage = self.get_storage_usage(user)
|
||||
|
||||
return {
|
||||
"files": files,
|
||||
"pagination": {
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"total_count": total_count,
|
||||
},
|
||||
"storage": usage,
|
||||
}
|
||||
|
||||
def delete_file(self, user: User, file_id: int) -> Dict[str, bool]:
|
||||
user_file = self.storage_repo.get_user_file_by_id(
|
||||
user.id, file_id, user.tenant_id
|
||||
)
|
||||
if user_file:
|
||||
s3_key = user_file.s3_key
|
||||
file_provider = getattr(user_file, "storage_provider", "b2")
|
||||
|
||||
user_file.is_deleted = True
|
||||
user_file.deleted_at = func.now()
|
||||
self.storage_repo.flush()
|
||||
|
||||
try:
|
||||
s3, quarantine_bucket, clean_bucket = get_storage_client(
|
||||
db=self.db,
|
||||
tenant_id=user.tenant_id,
|
||||
provider=file_provider,
|
||||
)
|
||||
defer_until_commit(
|
||||
self.db,
|
||||
lambda key=s3_key, client=s3, buckets=[clean_bucket, quarantine_bucket]:
|
||||
_best_effort_delete(client, buckets, key),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Could not resolve storage client for {s3_key}: {e}")
|
||||
|
||||
try:
|
||||
self.db.commit()
|
||||
self.invalidate_storage_cache(user.id)
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
self._dispatch_storage_alerts(user)
|
||||
|
||||
return {"success": True}
|
||||
@@ -41,10 +41,12 @@ class SaaSService:
|
||||
|
||||
@staticmethod
|
||||
def ensure_saas_user(db: Session, saas_data: Dict[str, Any]) -> Tuple[User, SaaSUserMapping]:
|
||||
"""
|
||||
Ensures a local user exists for the given SaaS user data.
|
||||
Maps the user if not already mapped.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
try:
|
||||
db.execute(text("SELECT set_config('docqube.bypass', 'on', true)"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
saas_user_id = str(saas_data.get("id"))
|
||||
email = saas_data.get("email")
|
||||
name = saas_data.get("name")
|
||||
@@ -94,6 +96,29 @@ class SaaSService:
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create role {role_name} for tenant {tenant.id}: {e}")
|
||||
|
||||
permissions = saas_data.get("metadata", {}).get("permissions", [])
|
||||
if target_role and permissions:
|
||||
try:
|
||||
access_rows = db.query(Access).filter(Access.access_code.in_(permissions)).all()
|
||||
existing_access_ids = {
|
||||
ra.access_id for ra in db.query(RoleAccess).filter(RoleAccess.role_id == target_role.id).all()
|
||||
}
|
||||
new_access_ids = {a.id for a in access_rows}
|
||||
|
||||
to_delete = existing_access_ids - new_access_ids
|
||||
if to_delete:
|
||||
db.query(RoleAccess).filter(
|
||||
RoleAccess.role_id == target_role.id,
|
||||
RoleAccess.access_id.in_(to_delete)
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
to_add = new_access_ids - existing_access_ids
|
||||
for aid in to_add:
|
||||
db.add(RoleAccess(role_id=target_role.id, access_id=aid))
|
||||
db.flush()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not sync role accesses for {target_role.name}: {e}")
|
||||
|
||||
mapping = db.query(SaaSUserMapping).filter(SaaSUserMapping.saas_user_id == saas_user_id).first()
|
||||
if mapping:
|
||||
user = mapping.user
|
||||
|
||||
@@ -1,54 +1,672 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Document Shared with You</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<title>A Document is Ready for You</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Albert+Sans:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet">
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #FFFFFF;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-main-card {
|
||||
width: 100% !important;
|
||||
border-radius: 24px !important;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
padding: 24px 18px 24px 18px !important;
|
||||
border-top-left-radius: 24px !important;
|
||||
border-top-right-radius: 24px !important;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
padding: 24px 16px 30px 16px !important;
|
||||
}
|
||||
|
||||
.feature-col {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
border-bottom: 1px solid #E7EFFF !important;
|
||||
padding: 16px 8px !important;
|
||||
}
|
||||
|
||||
.feature-col:last-child {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.feature-divider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.footer-row {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.footer-brand-left,
|
||||
.footer-brand-right {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
text-align: center !important;
|
||||
margin-bottom: 14px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #ffffff; color: #333333; margin: 0; padding: 0; line-height: 1.6;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h1 style="color: #2563EB; margin: 0; font-size: 28px; font-weight: 700;">DocQube</h1>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #ffffff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 40px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.02);">
|
||||
<h2 style="color: #111827; margin-top: 0; font-size: 22px; font-weight: 600;">A document has been shared with you</h2>
|
||||
<body style="background-color: #FFFFFF; margin: 0; padding: 36px 12px; -webkit-font-smoothing: antialiased;">
|
||||
<center style="width: 100%;">
|
||||
<table role="presentation" class="email-main-card" width="600" align="center"
|
||||
style="max-width: 600px; width: 100%; background-color: #FFFFFF; border-radius: 40px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02); overflow: hidden; margin: 0 auto; border: 1px solid #E2E8F0; border-collapse: separate; border-spacing: 0;">
|
||||
|
||||
<p style="color: #4b5563; font-size: 16px;">
|
||||
Hello {{ recipient_name }},
|
||||
</p>
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td class="header-section" align="center" bgcolor="#F3F6FD"
|
||||
style="background-color: #F3F6FD; border-top-left-radius: 40px; border-top-right-radius: 40px; padding: 36px 30px 29px 30px; text-align: center;">
|
||||
|
||||
<p style="color: #4b5563; font-size: 16px;">
|
||||
<strong style="color: #111827;">{{ sharer_name }}</strong> shared
|
||||
<strong style="color: #111827;">{{ document_name }}</strong> with you on DocQube.
|
||||
</p>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
|
||||
<div style="margin: 24px 0; padding: 16px 18px; background-color: #f8fafc; border: 1px solid #e5e7eb; border-radius: 10px;">
|
||||
<p style="margin: 0; color: #374151; font-size: 15px;">
|
||||
Access level: <strong style="color: #111827;">{{ permission }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<tr>
|
||||
<td align="center" style="text-align: center;">
|
||||
<img src="{{ asset_base_url }}/LogoDocqube.png" width="125" height="26"
|
||||
alt="DocQube"
|
||||
style="display: block; margin: 0 auto; border: 0; outline: none; text-decoration: none; max-width: 100%; height: auto;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<p style="color: #4b5563; font-size: 16px;">
|
||||
Use the button below to open the shared document. If you are not signed in yet, DocQube will ask you to log in first.
|
||||
</p>
|
||||
<tr>
|
||||
<td height="34"
|
||||
style="height: 34px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<div style="text-align: center; margin: 35px 0;">
|
||||
<a href="{{ document_url }}" style="background-color: #2563EB; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px; display: inline-block;">Open Shared Document</a>
|
||||
</div>
|
||||
<tr>
|
||||
<td align="center" style="text-align: center;">
|
||||
<img src="{{ asset_base_url }}/HeroEnvelope.png" width="270" height="152"
|
||||
alt="A document is ready for you"
|
||||
style="display: block; margin: 0 auto; border: 0; outline: none; text-decoration: none; max-width: 100%; height: auto;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<p style="color: #4b5563; font-size: 16px;">
|
||||
If you were not expecting this share, you can safely ignore this email.
|
||||
</p>
|
||||
<tr>
|
||||
<td height="15"
|
||||
style="height: 15px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<p style="color: #4b5563; font-size: 16px; margin-bottom: 0;">
|
||||
Best regards,<br>
|
||||
<strong style="color: #2563EB;">The DocQube Team</strong>
|
||||
</p>
|
||||
</div>
|
||||
<tr>
|
||||
<td align="center" style="text-align: center;">
|
||||
<h1
|
||||
style="margin: 0; padding: 0; color: #000000; font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 28px; font-weight: 600; line-height: 100%; mso-line-height-rule: exactly; letter-spacing: 0;">
|
||||
A Document is Ready for You</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<div style="text-align: center; margin-top: 30px; color: #9ca3af; font-size: 13px;">
|
||||
<p style="margin: 0;">© {{ year }} DocQube. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
<tr>
|
||||
<td height="13"
|
||||
style="height: 13px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="text-align: center; font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 600; line-height: 20px; mso-line-height-rule: exactly; letter-spacing: 0; color: #000000;">
|
||||
<img src="{{ asset_base_url }}/UserCircle.png" width="20" height="20" alt=""
|
||||
style="vertical-align: middle; border: 0; outline: none; text-decoration: none;"> <span
|
||||
style="color: #4949E6;">{{ sharer_name }}</span> has shared a document with you
|
||||
securely
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content Section: quote card, file card, CTA, trust row, footer -->
|
||||
<tr>
|
||||
<td class="content-section" style="padding: 36px 30px 30px 30px;">
|
||||
|
||||
<!-- Quote Card -->
|
||||
<!-- border-collapse: separate is required: under the global collapse rule a td's
|
||||
border-radius rounds its background but not its border, leaving white corner wedges -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
||||
style="border-collapse: separate; border-spacing: 0;">
|
||||
<tr>
|
||||
<td bgcolor="#FFFFFF"
|
||||
style="background-color: #FFFFFF; background-image: url('{{ asset_base_url }}/LogoWatermark.png'); background-repeat: no-repeat; background-position: right bottom; background-size: 92px 83px; border: 2px solid #ECEDFD; border-radius: 24px; padding: 16px 24px 20px 16px;">
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
|
||||
<td width="16" height="32" valign="top"
|
||||
style="width: 16px; height: 32px; vertical-align: top; padding: 0 10px 0 0; font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 32px; font-weight: 700; line-height: 32px; mso-line-height-rule: exactly; letter-spacing: 0; color: #4949E6;">
|
||||
“</td>
|
||||
|
||||
<td valign="top" style="vertical-align: top;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
border="0">
|
||||
|
||||
<tr>
|
||||
<td
|
||||
style="font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 22px; mso-line-height-rule: exactly; letter-spacing: 0; color: #242844;">
|
||||
Hi there,</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
style="font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 22px; mso-line-height-rule: exactly; letter-spacing: 0; color: #242844;">
|
||||
Please review the document we’ve shared with
|
||||
you.<br>We appreciate your time.<br>Thank you!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
style="font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 600; line-height: 22px; mso-line-height-rule: exactly; letter-spacing: 0; color: #4949E6;">
|
||||
— {{ sharer_name }}</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- File Card -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
||||
style="border-collapse: separate; border-spacing: 0;">
|
||||
|
||||
<tr>
|
||||
<td height="18"
|
||||
style="height: 18px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td bgcolor="#F1F1FD"
|
||||
style="background-color: #F1F1FD; background-image: linear-gradient(342.55deg, rgba(255, 255, 255, 0.1) 0%, rgba(65, 78, 231, 0.1) 100%); background-repeat: no-repeat; border-radius: 16px; padding: 16px 20px;">
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
|
||||
<td width="52" valign="middle"
|
||||
style="width: 52px; vertical-align: middle; padding: 0 22px 0 0; font-size: 0; line-height: 0;">
|
||||
<img src="{{ asset_base_url }}/FilePdf.png" width="36" height="36"
|
||||
alt="PDF"
|
||||
style="display: block; border: 0; outline: none; text-decoration: none; max-width: 100%;">
|
||||
</td>
|
||||
|
||||
<td valign="middle" style="vertical-align: middle;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
border="0">
|
||||
|
||||
<tr>
|
||||
<td
|
||||
style="font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 15px; font-weight: 700; line-height: 19px; mso-line-height-rule: exactly; letter-spacing: 0; color: #171A3A; word-break: break-word;">
|
||||
{{ document_name }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td height="5"
|
||||
style="height: 5px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
style="font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 400; line-height: 16px; mso-line-height-rule: exactly; letter-spacing: 0; color: #686D80;">
|
||||
{%- set _ext = document_name.rsplit('.', 1)[1] | upper if document_name and '.' in document_name else '' -%}
|
||||
{{ _ext ~ ' Document' if _ext else 'Document' }}</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<!-- CTA -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
|
||||
<tr>
|
||||
<td height="32"
|
||||
style="height: 32px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" style="text-align: center;">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{ document_url | default('#') }}" style="height:33px;v-text-anchor:middle;width:140px;" arcsize="24%" stroke="f" fillcolor="#4949E6">
|
||||
<w:anchorlock/>
|
||||
<center style="color:#FFFFFF;font-family:'Albert Sans', Helvetica, Arial, sans-serif;font-size:14px;font-weight:bold;">Open Document</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center">
|
||||
<tr>
|
||||
<td align="center" bgcolor="#4949E6"
|
||||
style="background-color: #4949E6; border-radius: 8px;">
|
||||
<a href="{{ document_url | default('#') }}"
|
||||
style="display: inline-block; padding: 8px 16px; font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 700; line-height: 17px; mso-line-height-rule: exactly; letter-spacing: 0; color: #FFFFFF; text-decoration: none; border-radius: 8px;">Open
|
||||
Document</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<!-- Privacy Card -->
|
||||
<!-- border-collapse: separate is required: under the global collapse rule a td's
|
||||
border-radius rounds its background but not its border, leaving white corner wedges -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
||||
style="border-collapse: separate; border-spacing: 0;">
|
||||
|
||||
<tr>
|
||||
<td height="36"
|
||||
style="height: 36px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td bgcolor="#F2FEF2"
|
||||
style="background-color: #F2FEF2; background-image: linear-gradient(342.55deg, rgba(255, 255, 255, 0.14) 0.87%, rgba(65, 231, 65, 0.14) 98.42%); background-repeat: no-repeat; border: 2px solid #E9FCE5; border-radius: 16px;">
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
|
||||
<td width="74" valign="middle"
|
||||
style="vertical-align: middle; padding: 14px 0 14px 18px;">
|
||||
<img src="{{ asset_base_url }}/LogoSecure.png" width="56" height="51"
|
||||
alt=""
|
||||
style="display: block; border: 0; outline: none; text-decoration: none; max-width: 100%;">
|
||||
</td>
|
||||
|
||||
<td valign="middle" style="vertical-align: middle; padding: 14px 18px 14px 15px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
border="0">
|
||||
|
||||
<tr>
|
||||
<td
|
||||
style="font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 700; line-height: 19px; mso-line-height-rule: exactly; letter-spacing: 0; color: #171A3A;">
|
||||
Your privacy matters</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td height="6"
|
||||
style="height: 6px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
style="font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 12px; font-weight: 400; line-height: 16px; mso-line-height-rule: exactly; letter-spacing: 0; color: #74798D;">
|
||||
This document is securely sent to you and intended only for the
|
||||
recipient.</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<!-- Trust Row -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
|
||||
<tr>
|
||||
<td height="44"
|
||||
style="height: 44px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
|
||||
<td class="feature-col" width="33%" align="center" valign="top"
|
||||
style="vertical-align: top; text-align: center;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
border="0">
|
||||
<tr>
|
||||
<td height="9"
|
||||
style="height: 9px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="text-align: center;">
|
||||
<img src="{{ asset_base_url }}/LogoDelivery.png"
|
||||
width="59" height="44" alt=""
|
||||
style="display: block; margin: 0 auto; border: 0; outline: none; text-decoration: none; border-radius: 4px; max-width: 100%;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="11"
|
||||
style="height: 11px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="text-align: center; font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 15px; font-weight: 700; line-height: 18px; mso-line-height-rule: exactly; letter-spacing: 0; color: #171A3A;">
|
||||
Secure Delivery</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="7"
|
||||
style="height: 7px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="text-align: center;">
|
||||
<table role="presentation" width="150" align="center"
|
||||
cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="text-align: center; font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 400; line-height: 16px; mso-line-height-rule: exactly; letter-spacing: 0; color: #686D80;">
|
||||
Encrypted and safe file transfer</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="5"
|
||||
style="height: 5px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
<td class="feature-divider" width="1" valign="middle"
|
||||
style="vertical-align: middle;">
|
||||
<table role="presentation" width="1" cellpadding="0" cellspacing="0"
|
||||
border="0">
|
||||
<tr>
|
||||
<td width="1" height="102" bgcolor="#E7EFFF"
|
||||
style="width: 1px; height: 102px; background-color: #E7EFFF; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
<td class="feature-col" width="33%" align="center" valign="top"
|
||||
style="vertical-align: top; text-align: center;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
border="0">
|
||||
<tr>
|
||||
<td height="9"
|
||||
style="height: 9px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="text-align: center;">
|
||||
<img src="{{ asset_base_url }}/LogoAccess.png"
|
||||
width="59" height="44" alt=""
|
||||
style="display: block; margin: 0 auto; border: 0; outline: none; text-decoration: none; border-radius: 4px; max-width: 100%;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="11"
|
||||
style="height: 11px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="text-align: center; font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 15px; font-weight: 700; line-height: 18px; mso-line-height-rule: exactly; letter-spacing: 0; color: #171A3A;">
|
||||
Always Accessible</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="7"
|
||||
style="height: 7px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="text-align: center;">
|
||||
<table role="presentation" width="150" align="center"
|
||||
cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="text-align: center; font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 400; line-height: 16px; mso-line-height-rule: exactly; letter-spacing: 0; color: #686D80;">
|
||||
Access your documents anytime, anywhere</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="5"
|
||||
style="height: 5px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
<td class="feature-divider" width="1" valign="middle"
|
||||
style="vertical-align: middle;">
|
||||
<table role="presentation" width="1" cellpadding="0" cellspacing="0"
|
||||
border="0">
|
||||
<tr>
|
||||
<td width="1" height="102" bgcolor="#E7EFFF"
|
||||
style="width: 1px; height: 102px; background-color: #E7EFFF; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
<td class="feature-col" width="33%" align="center" valign="top"
|
||||
style="vertical-align: top; text-align: center;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
border="0">
|
||||
<tr>
|
||||
<td height="9"
|
||||
style="height: 9px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="text-align: center;">
|
||||
<img src="{{ asset_base_url }}/LogoTrusted.png" width="59"
|
||||
height="44" alt=""
|
||||
style="display: block; margin: 0 auto; border: 0; outline: none; text-decoration: none; border-radius: 4px; max-width: 100%;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="11"
|
||||
style="height: 11px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="text-align: center; font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 15px; font-weight: 700; line-height: 18px; mso-line-height-rule: exactly; letter-spacing: 0; color: #171A3A;">
|
||||
Trusted Platform</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="7"
|
||||
style="height: 7px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="text-align: center;">
|
||||
<table role="presentation" width="150" align="center"
|
||||
cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="text-align: center; font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 400; line-height: 16px; mso-line-height-rule: exactly; letter-spacing: 0; color: #686D80;">
|
||||
Reliable. Professional. DocQube.</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="5"
|
||||
style="height: 5px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td height="40"
|
||||
style="height: 40px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td height="1" bgcolor="#E7EFFF"
|
||||
style="height: 1px; background-color: #E7EFFF; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td height="18"
|
||||
style="height: 18px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr class="footer-row">
|
||||
|
||||
<td class="footer-brand-left" align="left" valign="middle"
|
||||
style="vertical-align: middle; text-align: left; padding: 0 0 0 14px; font-size: 0; line-height: 0;">
|
||||
<img src="{{ asset_base_url }}/LogoDocqube.png" width="125"
|
||||
height="26" alt="DocQube"
|
||||
style="vertical-align: middle; border: 0; outline: none; text-decoration: none; max-width: 100%;">
|
||||
</td>
|
||||
|
||||
<td class="footer-brand-right" align="right" valign="middle"
|
||||
style="vertical-align: middle; text-align: right; padding: 0 15px 0 0; font-size: 0; line-height: 0;">
|
||||
<img src="{{ asset_base_url }}/LogoMaskan.png" width="121" height="30"
|
||||
alt="Maskan Technologies"
|
||||
style="vertical-align: middle; border: 0; outline: none; text-decoration: none; max-width: 100%;">
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td height="20"
|
||||
style="height: 20px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<table role="presentation" width="74%" align="center" style="width: 74.3%;"
|
||||
cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td height="1" bgcolor="#E7EFFF"
|
||||
style="height: 1px; background-color: #E7EFFF; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td height="11"
|
||||
style="height: 11px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="text-align: center; font-family: 'Albert Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 12px; font-weight: 400; line-height: 14px; mso-line-height-rule: exactly; letter-spacing: 0; color: #A8ACCD;">
|
||||
© {{ year | default('2026') }} DocQube. All rights reserved.<br>
|
||||
If you weren't expecting this email, you can safely ignore it.</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
+562
-391
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ dependencies = [
|
||||
"python-jose==3.3.0",
|
||||
"passlib==1.7.4",
|
||||
"bcrypt==5.0.0",
|
||||
"cffi>=1.17.0",
|
||||
"cryptography==48.0.0",
|
||||
"google-auth==2.57.0",
|
||||
"sqlalchemy==2.0.36",
|
||||
|
||||
@@ -594,6 +594,7 @@ dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "boto3" },
|
||||
{ name = "celery" },
|
||||
{ name = "cffi" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "docx2txt" },
|
||||
{ name = "email-validator" },
|
||||
@@ -664,6 +665,7 @@ requires-dist = [
|
||||
{ name = "beautifulsoup4", specifier = "==4.13.5" },
|
||||
{ name = "boto3", specifier = "==1.36.23" },
|
||||
{ name = "celery", specifier = "==5.4.0" },
|
||||
{ name = "cffi", specifier = ">=1.17.0" },
|
||||
{ name = "cryptography", specifier = "==48.0.0" },
|
||||
{ name = "docx2txt", specifier = "==0.9" },
|
||||
{ name = "email-validator", specifier = "==2.3.0" },
|
||||
@@ -1443,8 +1445,8 @@ name = "mkl"
|
||||
version = "2021.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "intel-openmp", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||
{ name = "tbb", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||
{ name = "intel-openmp" },
|
||||
{ name = "tbb" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/c6/892fe3bc91e811b78e4f85653864f2d92541d5e5c306b0cb3c2311e9ca64/mkl-2021.4.0-py2.py3-none-win32.whl", hash = "sha256:439c640b269a5668134e3dcbcea4350459c4a8bc46469669b2d67e07e3d330e8", size = 129048357, upload-time = "2021-09-28T17:08:58.256Z" },
|
||||
|
||||
Reference in New Issue
Block a user