a08b103271
Merges origin/main (v0.156.0) into the classification feature branch, properly resolving all 23 merge conflicts: - Auto-generated files (BUILD_DATE, VERSION, etc.): accept main's version - Non-classification files (SharePoint, QR auth, session mgmt, mobile): accept main's version - Classification files (api/__init__.py, models.py, migrations/env.py, conftest.py): keep classification additions alongside main's content Previously the branch was incorrectly removing files from main (SharePoint integration, QR scanner, session management). This merge properly preserves all main branch content while maintaining the classification feature additions. Migration chain validated: 038_add_classification_rules chains from 037_add_user_sessions_and_qr_challenges. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
872 lines
34 KiB
Python
872 lines
34 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Upload dispatcher for user-specific destination integrations.
|
||
|
||
This module provides a Celery task that uploads a processed document to a
|
||
specific ``UserIntegration`` record using that integration's own stored
|
||
config and decrypted credentials — instead of the global application settings.
|
||
|
||
It is the per-destination counterpart of :func:`send_to_all_destinations`
|
||
and is dispatched by :func:`send_to_user_destinations` once per active
|
||
DESTINATION integration that belongs to the document's owner.
|
||
|
||
Credential shapes per integration type (mirrors the UserIntegration docstring):
|
||
|
||
DROPBOX credentials = {"refresh_token", "app_key", "app_secret"}
|
||
config = {"folder": "/DocuElevate"}
|
||
|
||
S3 credentials = {"access_key_id", "secret_access_key"}
|
||
config = {"bucket", "region", "endpoint_url", "folder_prefix"}
|
||
|
||
GOOGLE_DRIVE
|
||
OAuth credentials = {"client_id", "client_secret", "refresh_token"}
|
||
config = {"folder_id"}
|
||
SA credentials = {"credentials_json"}
|
||
config = {"folder_id"}
|
||
|
||
ONEDRIVE credentials = {"client_id", "client_secret", "refresh_token"}
|
||
config = {"folder_path", "tenant_id"}
|
||
|
||
WEBDAV /
|
||
NEXTCLOUD credentials = {"username", "password"}
|
||
config = {"url", "folder"}
|
||
|
||
FTP credentials = {"password"}
|
||
config = {"host", "username", "port", "folder", "use_tls"}
|
||
|
||
SFTP credentials = {"password"} or {"private_key"}
|
||
config = {"host", "username", "port", "folder"}
|
||
|
||
EMAIL credentials = {"password"}
|
||
config = {"host", "username", "port", "recipient",
|
||
"use_tls", "sender_name"}
|
||
|
||
PAPERLESS credentials = {"api_token"}
|
||
config = {"host"}
|
||
|
||
RCLONE credentials = {"rclone_conf"} (full rclone config file text)
|
||
config = {"remote": "myremote:", "folder": "dest/path"}
|
||
"""
|
||
|
||
import ftplib # nosec B402
|
||
import json
|
||
import logging
|
||
import os
|
||
import subprocess # nosec B404
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
from urllib.parse import urljoin
|
||
|
||
from app.celery_app import celery
|
||
from app.database import SessionLocal
|
||
from app.models import IntegrationType, UserIntegration
|
||
from app.tasks.retry_config import UploadTaskWithRetry
|
||
from app.utils.encryption import decrypt_value
|
||
from app.utils.logging import log_task_progress
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Maximum characters to store in UserIntegration.last_error
|
||
_MAX_ERROR_LENGTH = 500
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Per-type upload helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _upload_dropbox(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||
"""Upload *file_path* to Dropbox using per-user OAuth credentials."""
|
||
import dropbox
|
||
|
||
app_key = creds.get("app_key") or ""
|
||
app_secret = creds.get("app_secret") or ""
|
||
refresh_token = creds.get("refresh_token") or ""
|
||
|
||
if not (app_key and app_secret and refresh_token):
|
||
raise ValueError("Dropbox integration is missing app_key, app_secret or refresh_token in credentials")
|
||
|
||
dbx = dropbox.Dropbox(app_key=app_key, app_secret=app_secret, oauth2_refresh_token=refresh_token)
|
||
|
||
remote_folder = cfg.get("folder", "/DocuElevate").rstrip("/")
|
||
filename = os.path.basename(file_path)
|
||
remote_path = f"{remote_folder}/{filename}"
|
||
if not remote_path.startswith("/"):
|
||
remote_path = "/" + remote_path
|
||
|
||
file_size = os.path.getsize(file_path)
|
||
with open(file_path, "rb") as fh:
|
||
if file_size > 10 * 1024 * 1024:
|
||
chunk_size = 4 * 1024 * 1024
|
||
session_start = dbx.files_upload_session_start(fh.read(chunk_size))
|
||
cursor = dropbox.files.UploadSessionCursor(session_start.session_id, fh.tell())
|
||
while fh.tell() < file_size:
|
||
if (file_size - fh.tell()) <= chunk_size:
|
||
dbx.files_upload_session_finish(
|
||
fh.read(chunk_size),
|
||
cursor,
|
||
dropbox.files.CommitInfo(path=remote_path, mode=dropbox.files.WriteMode.overwrite),
|
||
)
|
||
else:
|
||
dbx.files_upload_session_append_v2(fh.read(chunk_size), cursor)
|
||
cursor.offset = fh.tell()
|
||
else:
|
||
dbx.files_upload(fh.read(), remote_path, mode=dropbox.files.WriteMode.overwrite)
|
||
|
||
logger.info("[%s] Dropbox upload complete: %s", task_id, remote_path)
|
||
return {"status": "Completed", "dropbox_path": remote_path}
|
||
|
||
|
||
def _upload_s3(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||
"""Upload *file_path* to Amazon S3 (or S3-compatible) using per-user credentials."""
|
||
import boto3
|
||
from botocore.exceptions import ClientError
|
||
|
||
bucket = cfg.get("bucket") or ""
|
||
region = cfg.get("region") or "us-east-1"
|
||
endpoint_url = cfg.get("endpoint_url") or None
|
||
folder_prefix = cfg.get("folder_prefix") or ""
|
||
storage_class = cfg.get("storage_class") or "STANDARD"
|
||
|
||
access_key = creds.get("access_key_id") or ""
|
||
secret_key = creds.get("secret_access_key") or ""
|
||
|
||
if not bucket:
|
||
raise ValueError("S3 integration is missing bucket in config")
|
||
if not (access_key and secret_key):
|
||
raise ValueError("S3 integration is missing access_key_id or secret_access_key in credentials")
|
||
|
||
client_kwargs: dict[str, Any] = {
|
||
"region_name": region,
|
||
"aws_access_key_id": access_key,
|
||
"aws_secret_access_key": secret_key,
|
||
}
|
||
if endpoint_url:
|
||
client_kwargs["endpoint_url"] = endpoint_url
|
||
|
||
s3 = boto3.client("s3", **client_kwargs)
|
||
|
||
filename = os.path.basename(file_path)
|
||
s3_key = f"{folder_prefix.rstrip('/')}/{filename}" if folder_prefix else filename
|
||
|
||
try:
|
||
s3.upload_file(file_path, bucket, s3_key, ExtraArgs={"StorageClass": storage_class})
|
||
except ClientError as exc:
|
||
raise RuntimeError(f"S3 upload failed: {exc}") from exc
|
||
|
||
logger.info("[%s] S3 upload complete: s3://%s/%s", task_id, bucket, s3_key)
|
||
return {"status": "Completed", "s3_bucket": bucket, "s3_key": s3_key}
|
||
|
||
|
||
def _upload_google_drive(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||
"""Upload *file_path* to Google Drive using per-user OAuth or service-account credentials."""
|
||
from googleapiclient.discovery import build
|
||
from googleapiclient.http import MediaFileUpload
|
||
|
||
folder_id = cfg.get("folder_id") or ""
|
||
filename = os.path.basename(file_path)
|
||
|
||
# Prefer OAuth credentials (client_id + client_secret + refresh_token)
|
||
client_id = creds.get("client_id") or ""
|
||
client_secret = creds.get("client_secret") or ""
|
||
refresh_token = creds.get("refresh_token") or ""
|
||
credentials_json = creds.get("credentials_json") or ""
|
||
|
||
if client_id and client_secret and refresh_token:
|
||
from google.auth.transport.requests import Request
|
||
from google.oauth2.credentials import Credentials as OAuthCredentials
|
||
|
||
google_creds = OAuthCredentials(
|
||
None,
|
||
refresh_token=refresh_token,
|
||
token_uri="https://oauth2.googleapis.com/token",
|
||
client_id=client_id,
|
||
client_secret=client_secret,
|
||
scopes=["https://www.googleapis.com/auth/drive.file"],
|
||
)
|
||
google_creds.refresh(Request())
|
||
service = build("drive", "v3", credentials=google_creds)
|
||
elif credentials_json:
|
||
from google.oauth2.service_account import Credentials as SACredentials
|
||
|
||
creds_dict = json.loads(credentials_json)
|
||
sa_creds = SACredentials.from_service_account_info(creds_dict, scopes=["https://www.googleapis.com/auth/drive"])
|
||
service = build("drive", "v3", credentials=sa_creds)
|
||
else:
|
||
raise ValueError("Google Drive integration requires either OAuth credentials or credentials_json")
|
||
|
||
file_metadata: dict[str, Any] = {"name": filename}
|
||
if folder_id:
|
||
file_metadata["parents"] = [folder_id]
|
||
|
||
media = MediaFileUpload(file_path, mimetype="application/pdf", resumable=True)
|
||
file_obj = service.files().create(body=file_metadata, media_body=media, fields="id,name,webViewLink").execute()
|
||
|
||
gdrive_id = file_obj.get("id")
|
||
web_link = file_obj.get("webViewLink")
|
||
logger.info("[%s] Google Drive upload complete: %s (%s)", task_id, gdrive_id, web_link)
|
||
return {"status": "Completed", "google_drive_file_id": gdrive_id, "web_link": web_link}
|
||
|
||
|
||
def _upload_onedrive(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||
"""Upload *file_path* to OneDrive using per-user MSAL credentials."""
|
||
import urllib.parse
|
||
|
||
import msal
|
||
import requests as _requests
|
||
|
||
client_id = creds.get("client_id") or ""
|
||
client_secret = creds.get("client_secret") or ""
|
||
refresh_token = creds.get("refresh_token") or ""
|
||
tenant = cfg.get("tenant_id") or "common"
|
||
folder_path = cfg.get("folder_path") or ""
|
||
|
||
if not (client_id and client_secret):
|
||
raise ValueError("OneDrive integration is missing client_id or client_secret in credentials")
|
||
|
||
scopes = ["https://graph.microsoft.com/.default"]
|
||
msal_app = msal.ConfidentialClientApplication(
|
||
client_id=client_id,
|
||
client_credential=client_secret,
|
||
authority=f"https://login.microsoftonline.com/{tenant}",
|
||
)
|
||
|
||
if refresh_token:
|
||
token_resp = msal_app.acquire_token_by_refresh_token(refresh_token=refresh_token, scopes=scopes)
|
||
else:
|
||
token_resp = msal_app.acquire_token_for_client(scopes=scopes)
|
||
|
||
if "access_token" not in token_resp:
|
||
raise ValueError(f"OneDrive token acquisition failed: {token_resp.get('error_description', 'unknown')}")
|
||
|
||
access_token = token_resp["access_token"]
|
||
filename = os.path.basename(file_path)
|
||
|
||
# Build upload-session URL
|
||
if folder_path:
|
||
folder_path = folder_path.strip("/")
|
||
encoded_path = "/".join(urllib.parse.quote(p) for p in folder_path.split("/"))
|
||
encoded_file = urllib.parse.quote(filename)
|
||
item_path = f"/root:/{encoded_path}/{encoded_file}:/createUploadSession"
|
||
else:
|
||
encoded_file = urllib.parse.quote(filename)
|
||
item_path = f"/root:/{encoded_file}:/createUploadSession"
|
||
|
||
session_url = f"https://graph.microsoft.com/v1.0/me/drive{item_path}"
|
||
headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
|
||
resp = _requests.post(
|
||
session_url, headers=headers, json={"item": {"@microsoft.graph.conflictBehavior": "replace"}}, timeout=30
|
||
)
|
||
resp.raise_for_status()
|
||
upload_url = resp.json()["uploadUrl"]
|
||
|
||
file_size = os.path.getsize(file_path)
|
||
chunk_size = 10 * 1024 * 1024
|
||
with open(file_path, "rb") as fh:
|
||
chunk_num = 0
|
||
while True:
|
||
chunk = fh.read(chunk_size)
|
||
if not chunk:
|
||
break
|
||
start = chunk_num * chunk_size
|
||
end = start + len(chunk) - 1
|
||
upload_headers = {
|
||
"Content-Length": str(len(chunk)),
|
||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||
}
|
||
upload_resp = _requests.put(upload_url, headers=upload_headers, data=chunk, timeout=120)
|
||
if upload_resp.status_code not in (201, 202):
|
||
raise RuntimeError(f"OneDrive chunk upload failed: {upload_resp.status_code}")
|
||
chunk_num += 1
|
||
|
||
logger.info("[%s] OneDrive upload complete: %s/%s", task_id, folder_path, filename)
|
||
return {"status": "Completed", "onedrive_folder": folder_path, "filename": filename}
|
||
|
||
|
||
def _upload_webdav(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||
"""Upload *file_path* to a WebDAV server using per-user credentials."""
|
||
import requests as _requests
|
||
|
||
url = cfg.get("url") or ""
|
||
folder = cfg.get("folder") or ""
|
||
username = creds.get("username") or ""
|
||
password = creds.get("password") or ""
|
||
verify_ssl = cfg.get("verify_ssl", True)
|
||
|
||
if not url:
|
||
raise ValueError("WebDAV integration is missing url in config")
|
||
|
||
filename = os.path.basename(file_path)
|
||
folder = folder.lstrip("/")
|
||
target = urljoin(url.rstrip("/") + "/", folder)
|
||
if not target.endswith("/"):
|
||
target += "/"
|
||
dest = urljoin(target, filename)
|
||
|
||
with open(file_path, "rb") as fh:
|
||
resp = _requests.put(dest, auth=(username, password), data=fh, verify=verify_ssl, timeout=120)
|
||
|
||
if resp.status_code not in (200, 201, 204):
|
||
raise RuntimeError(f"WebDAV upload failed: {resp.status_code} {resp.text[:200]}")
|
||
|
||
logger.info("[%s] WebDAV upload complete: %s", task_id, dest)
|
||
return {"status": "Completed", "webdav_url": dest}
|
||
|
||
|
||
def _upload_nextcloud(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||
"""Upload *file_path* to Nextcloud (WebDAV) using per-user credentials."""
|
||
# Nextcloud uses WebDAV under the hood; reuse the WebDAV helper.
|
||
return _upload_webdav(file_path, cfg, creds, task_id)
|
||
|
||
|
||
def _upload_ftp(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||
"""Upload *file_path* to an FTP/FTPS server using per-user credentials."""
|
||
host = cfg.get("host") or ""
|
||
port = int(cfg.get("port") or 21)
|
||
username = cfg.get("username") or ""
|
||
folder = cfg.get("folder") or ""
|
||
use_tls = cfg.get("use_tls", True)
|
||
password = creds.get("password") or ""
|
||
filename = os.path.basename(file_path)
|
||
|
||
if not host:
|
||
raise ValueError("FTP integration is missing host in config")
|
||
|
||
ftp: ftplib.FTP
|
||
if use_tls:
|
||
ftp = ftplib.FTP_TLS() # nosec B321 # noqa: S321
|
||
ftp.connect(host=host, port=port)
|
||
ftp.login(user=username, passwd=password)
|
||
ftp.prot_p()
|
||
else:
|
||
ftp = ftplib.FTP() # nosec B321 # noqa: S321
|
||
ftp.connect(host=host, port=port)
|
||
ftp.login(user=username, passwd=password)
|
||
|
||
if folder:
|
||
folder_stripped = folder.lstrip("/")
|
||
try:
|
||
ftp.cwd(folder_stripped)
|
||
except ftplib.error_perm:
|
||
parts = folder_stripped.split("/")
|
||
current = ""
|
||
for part in parts:
|
||
if not part:
|
||
continue
|
||
current += f"/{part}"
|
||
try:
|
||
ftp.cwd(current)
|
||
except ftplib.error_perm:
|
||
ftp.mkd(current)
|
||
ftp.cwd(current)
|
||
|
||
with open(file_path, "rb") as fh:
|
||
ftp.storbinary(f"STOR {filename}", fh)
|
||
ftp.quit()
|
||
|
||
logger.info("[%s] FTP upload complete: %s/%s", task_id, host, filename)
|
||
return {"status": "Completed", "ftp_host": host, "filename": filename}
|
||
|
||
|
||
def _upload_sftp(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||
"""Upload *file_path* to an SFTP server using per-user credentials."""
|
||
import paramiko
|
||
|
||
host = cfg.get("host") or ""
|
||
port = int(cfg.get("port") or 22)
|
||
username = cfg.get("username") or ""
|
||
folder = cfg.get("folder") or ""
|
||
password = creds.get("password") or ""
|
||
private_key_text = creds.get("private_key") or ""
|
||
filename = os.path.basename(file_path)
|
||
|
||
if not host:
|
||
raise ValueError("SFTP integration is missing host in config")
|
||
|
||
ssh = paramiko.SSHClient()
|
||
ssh.load_system_host_keys()
|
||
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||
|
||
connect_kwargs: dict[str, Any] = {"hostname": host, "port": port, "username": username}
|
||
if private_key_text:
|
||
import io
|
||
|
||
pkey = paramiko.RSAKey.from_private_key(io.StringIO(private_key_text))
|
||
connect_kwargs["pkey"] = pkey
|
||
elif password:
|
||
connect_kwargs["password"] = password
|
||
else:
|
||
raise ValueError("SFTP integration requires password or private_key in credentials")
|
||
|
||
ssh.connect(**connect_kwargs)
|
||
sftp = ssh.open_sftp()
|
||
|
||
remote_path = f"{folder.rstrip('/')}/{filename}" if folder else filename
|
||
if folder and folder.startswith("/") and not remote_path.startswith("/"):
|
||
remote_path = "/" + remote_path
|
||
|
||
sftp.put(file_path, remote_path)
|
||
sftp.close()
|
||
ssh.close()
|
||
|
||
logger.info("[%s] SFTP upload complete: %s:%s", task_id, host, remote_path)
|
||
return {"status": "Completed", "sftp_host": host, "sftp_path": remote_path}
|
||
|
||
|
||
def _upload_paperless(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||
"""Upload *file_path* to a Paperless-ngx instance using per-user API token."""
|
||
import time
|
||
|
||
import requests as _requests
|
||
|
||
host = (cfg.get("host") or "").rstrip("/")
|
||
api_token = creds.get("api_token") or ""
|
||
filename = os.path.basename(file_path)
|
||
|
||
if not host:
|
||
raise ValueError("Paperless integration is missing host in config")
|
||
if not api_token:
|
||
raise ValueError("Paperless integration is missing api_token in credentials")
|
||
|
||
headers = {"Authorization": f"Token {api_token}"}
|
||
post_url = f"{host}/api/documents/post_document/"
|
||
|
||
with open(file_path, "rb") as fh:
|
||
resp = _requests.post(
|
||
post_url,
|
||
headers=headers,
|
||
files={"document": (filename, fh, "application/pdf")},
|
||
data={"title": filename},
|
||
timeout=120,
|
||
)
|
||
resp.raise_for_status()
|
||
raw_task_id = resp.text.strip().strip('"').strip("'")
|
||
|
||
# Poll for completion (up to 30 s)
|
||
task_url = f"{host}/api/tasks/"
|
||
doc_id = None
|
||
for _ in range(10):
|
||
time.sleep(3)
|
||
try:
|
||
poll_resp = _requests.get(task_url, headers=headers, params={"task_id": raw_task_id}, timeout=30)
|
||
poll_resp.raise_for_status()
|
||
tasks_data = poll_resp.json()
|
||
if isinstance(tasks_data, dict) and "results" in tasks_data:
|
||
tasks_data = tasks_data["results"]
|
||
if tasks_data:
|
||
info = tasks_data[0]
|
||
status = info.get("status")
|
||
if status == "SUCCESS":
|
||
doc_id = info.get("related_document")
|
||
break
|
||
elif status == "FAILURE":
|
||
raise RuntimeError(f"Paperless processing failed: {info.get('result')}")
|
||
except RuntimeError:
|
||
raise
|
||
except Exception as poll_exc:
|
||
logger.warning("[%s] Paperless poll error: %s", task_id, poll_exc)
|
||
|
||
logger.info("[%s] Paperless upload complete: doc_id=%s", task_id, doc_id)
|
||
return {"status": "Completed", "paperless_host": host, "paperless_document_id": doc_id}
|
||
|
||
|
||
def _upload_email(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||
"""Send *file_path* as an email attachment using per-user SMTP credentials."""
|
||
import smtplib
|
||
from email.mime.application import MIMEApplication
|
||
from email.mime.multipart import MIMEMultipart
|
||
from email.mime.text import MIMEText
|
||
|
||
host = cfg.get("host") or ""
|
||
port = int(cfg.get("port") or 587)
|
||
username = cfg.get("username") or ""
|
||
recipient = cfg.get("recipient") or ""
|
||
use_tls = cfg.get("use_tls", True)
|
||
sender_name = cfg.get("sender_name") or "DocuElevate"
|
||
password = creds.get("password") or ""
|
||
filename = os.path.basename(file_path)
|
||
|
||
if not (host and recipient):
|
||
raise ValueError("Email integration is missing host or recipient in config")
|
||
|
||
msg = MIMEMultipart()
|
||
msg["From"] = f"{sender_name} <{username}>" if username else sender_name
|
||
msg["To"] = recipient
|
||
msg["Subject"] = f"Document: {filename}"
|
||
msg.attach(MIMEText(f"Please find the attached document: {filename}", "plain"))
|
||
|
||
with open(file_path, "rb") as fh:
|
||
part = MIMEApplication(fh.read(), Name=filename)
|
||
part["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||
msg.attach(part)
|
||
|
||
if use_tls:
|
||
import ssl
|
||
|
||
tls_context = ssl.create_default_context()
|
||
with smtplib.SMTP(host, port, timeout=30) as smtp:
|
||
smtp.starttls(context=tls_context)
|
||
if username and password:
|
||
smtp.login(username, password)
|
||
smtp.sendmail(msg["From"], [recipient], msg.as_string())
|
||
else:
|
||
# Plaintext SMTP — only use when explicitly configured and TLS is unavailable.
|
||
# Credentials and content will be transmitted without encryption.
|
||
with smtplib.SMTP(host, port, timeout=30) as smtp: # nosec B608
|
||
if username and password:
|
||
smtp.login(username, password)
|
||
smtp.sendmail(msg["From"], [recipient], msg.as_string())
|
||
|
||
logger.info("[%s] Email upload complete: sent to %s", task_id, recipient)
|
||
return {"status": "Completed", "recipient": recipient}
|
||
|
||
|
||
def _upload_rclone(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||
"""Copy *file_path* to an rclone remote using per-user rclone config."""
|
||
import re
|
||
import tempfile
|
||
|
||
remote = cfg.get("remote") or ""
|
||
folder = cfg.get("folder") or ""
|
||
rclone_conf_text = creds.get("rclone_conf") or ""
|
||
filename = os.path.basename(file_path)
|
||
|
||
if not remote:
|
||
raise ValueError("Rclone integration is missing remote in config")
|
||
if not rclone_conf_text:
|
||
raise ValueError("Rclone integration is missing rclone_conf in credentials")
|
||
|
||
# Validate remote and folder to prevent shell metacharacter injection.
|
||
# rclone remote names are alphanumeric + hyphens/underscores followed by ':'.
|
||
# folder paths must not contain shell-dangerous characters.
|
||
_SAFE_REMOTE_RE = re.compile(r"^[A-Za-z0-9_\-]+:(/[A-Za-z0-9_.@\-/ ]*)?$")
|
||
_SAFE_FOLDER_RE = re.compile(r"^[A-Za-z0-9_.@\-/ ]*$")
|
||
if not _SAFE_REMOTE_RE.match(remote):
|
||
raise ValueError(f"Rclone remote contains unsafe characters: {remote!r}")
|
||
if folder and not _SAFE_FOLDER_RE.match(folder):
|
||
raise ValueError(f"Rclone folder contains unsafe characters: {folder!r}")
|
||
|
||
# Write the user's rclone config to a temp file so we don't touch the system config
|
||
with tempfile.NamedTemporaryFile(mode="w", suffix=".conf", delete=False) as tmp_conf:
|
||
tmp_conf.write(rclone_conf_text)
|
||
conf_path = tmp_conf.name
|
||
|
||
dest = f"{remote.rstrip('/')}/{folder.strip('/')}/{filename}" if folder else f"{remote.rstrip('/')}/{filename}"
|
||
dest = dest.replace("//", "/")
|
||
|
||
try:
|
||
result = subprocess.run( # nosec B603 # noqa: S603 S607
|
||
["rclone", "copyto", f"--config={conf_path}", file_path, dest], # noqa: S603 S607
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=300,
|
||
check=False,
|
||
)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"rclone exited {result.returncode}: {result.stderr[:300]}")
|
||
finally:
|
||
os.unlink(conf_path)
|
||
|
||
logger.info("[%s] Rclone upload complete: %s", task_id, dest)
|
||
return {"status": "Completed", "rclone_dest": dest}
|
||
|
||
|
||
def _upload_sharepoint(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||
"""Upload *file_path* to SharePoint using per-user MSAL credentials."""
|
||
import urllib.parse
|
||
|
||
import msal
|
||
import requests as _requests
|
||
|
||
client_id = creds.get("client_id") or ""
|
||
client_secret = creds.get("client_secret") or ""
|
||
refresh_token = creds.get("refresh_token") or ""
|
||
tenant = cfg.get("tenant_id") or "common"
|
||
site_url = cfg.get("site_url") or ""
|
||
library_name = cfg.get("document_library") or "Documents"
|
||
folder_path = cfg.get("folder_path") or ""
|
||
|
||
if not (client_id and client_secret):
|
||
raise ValueError("SharePoint integration is missing client_id or client_secret in credentials")
|
||
if not site_url:
|
||
raise ValueError("SharePoint integration is missing site_url in config")
|
||
|
||
scopes = ["https://graph.microsoft.com/.default"]
|
||
msal_app = msal.ConfidentialClientApplication(
|
||
client_id=client_id,
|
||
client_credential=client_secret,
|
||
authority=f"https://login.microsoftonline.com/{tenant}",
|
||
)
|
||
|
||
if refresh_token:
|
||
token_resp = msal_app.acquire_token_by_refresh_token(refresh_token=refresh_token, scopes=scopes)
|
||
else:
|
||
token_resp = msal_app.acquire_token_for_client(scopes=scopes)
|
||
|
||
if "access_token" not in token_resp:
|
||
raise ValueError(f"SharePoint token acquisition failed: {token_resp.get('error_description', 'unknown')}")
|
||
|
||
access_token = token_resp["access_token"]
|
||
headers = {"Authorization": f"Bearer {access_token}"}
|
||
|
||
# Resolve site ID
|
||
parsed = urllib.parse.urlparse(site_url)
|
||
hostname = parsed.hostname
|
||
site_path = parsed.path.rstrip("/")
|
||
if not hostname or not site_path:
|
||
raise ValueError(f"Invalid SharePoint site URL: {site_url}")
|
||
|
||
resp = _requests.get(f"https://graph.microsoft.com/v1.0/sites/{hostname}:{site_path}", headers=headers, timeout=30)
|
||
resp.raise_for_status()
|
||
site_id = resp.json()["id"]
|
||
|
||
# Resolve drive ID
|
||
resp = _requests.get(f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives", headers=headers, timeout=30)
|
||
resp.raise_for_status()
|
||
drive_id = None
|
||
for drive in resp.json().get("value", []):
|
||
if drive.get("name", "").lower() == library_name.lower():
|
||
drive_id = drive["id"]
|
||
break
|
||
if not drive_id:
|
||
raise RuntimeError(f"Document library '{library_name}' not found on SharePoint site")
|
||
|
||
filename = os.path.basename(file_path)
|
||
|
||
# Build upload-session URL
|
||
base_url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives/{drive_id}"
|
||
if folder_path:
|
||
folder_path = folder_path.strip("/")
|
||
encoded_path = "/".join(urllib.parse.quote(p) for p in folder_path.split("/"))
|
||
encoded_file = urllib.parse.quote(filename)
|
||
item_path = f"/root:/{encoded_path}/{encoded_file}:/createUploadSession"
|
||
else:
|
||
encoded_file = urllib.parse.quote(filename)
|
||
item_path = f"/root:/{encoded_file}:/createUploadSession"
|
||
|
||
session_url = f"{base_url}{item_path}"
|
||
session_headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
|
||
resp = _requests.post(
|
||
session_url,
|
||
headers=session_headers,
|
||
json={"item": {"@microsoft.graph.conflictBehavior": "replace"}},
|
||
timeout=30,
|
||
)
|
||
resp.raise_for_status()
|
||
upload_url = resp.json()["uploadUrl"]
|
||
|
||
file_size = os.path.getsize(file_path)
|
||
chunk_size = 10 * 1024 * 1024
|
||
with open(file_path, "rb") as fh:
|
||
chunk_num = 0
|
||
while True:
|
||
chunk = fh.read(chunk_size)
|
||
if not chunk:
|
||
break
|
||
start = chunk_num * chunk_size
|
||
end = start + len(chunk) - 1
|
||
upload_headers = {
|
||
"Content-Length": str(len(chunk)),
|
||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||
}
|
||
upload_resp = _requests.put(upload_url, headers=upload_headers, data=chunk, timeout=120)
|
||
if upload_resp.status_code not in (201, 202):
|
||
raise RuntimeError(f"SharePoint chunk upload failed: {upload_resp.status_code}")
|
||
chunk_num += 1
|
||
|
||
logger.info("[%s] SharePoint upload complete: %s/%s", task_id, folder_path, filename)
|
||
return {"status": "Completed", "sharepoint_folder": folder_path, "filename": filename}
|
||
|
||
|
||
def _upload_icloud(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
|
||
"""Upload *file_path* to iCloud Drive using per-user credentials.
|
||
|
||
Expected *cfg* keys:
|
||
* ``folder`` – target folder path inside iCloud Drive (e.g. ``Documents/Uploads``).
|
||
* ``cookie_directory`` – (optional) path for session cookie persistence.
|
||
|
||
Expected *creds* keys:
|
||
* ``username`` – Apple ID email address.
|
||
* ``password`` – app-specific password.
|
||
"""
|
||
from app.tasks.upload_to_icloud import _get_icloud_api, _navigate_to_folder
|
||
|
||
username = creds.get("username") or ""
|
||
password = creds.get("password") or ""
|
||
folder = cfg.get("folder") or ""
|
||
cookie_directory = cfg.get("cookie_directory") or None
|
||
|
||
if not username or not password:
|
||
raise ValueError("iCloud integration is missing username or password in credentials")
|
||
|
||
api = _get_icloud_api(username, password, cookie_directory)
|
||
folder_node = _navigate_to_folder(api.drive, folder)
|
||
|
||
with open(file_path, "rb") as fh:
|
||
folder_node.upload(fh)
|
||
|
||
logger.info("[%s] iCloud Drive upload complete: folder=%s", task_id, folder or "/")
|
||
return {"status": "Completed", "icloud_folder": folder or "/"}
|
||
|
||
|
||
# Map IntegrationType → upload helper
|
||
_UPLOAD_HANDLERS = {
|
||
IntegrationType.DROPBOX: _upload_dropbox,
|
||
IntegrationType.S3: _upload_s3,
|
||
IntegrationType.GOOGLE_DRIVE: _upload_google_drive,
|
||
IntegrationType.ONEDRIVE: _upload_onedrive,
|
||
IntegrationType.WEBDAV: _upload_webdav,
|
||
IntegrationType.NEXTCLOUD: _upload_nextcloud,
|
||
IntegrationType.FTP: _upload_ftp,
|
||
IntegrationType.SFTP: _upload_sftp,
|
||
IntegrationType.PAPERLESS: _upload_paperless,
|
||
IntegrationType.EMAIL: _upload_email,
|
||
IntegrationType.RCLONE: _upload_rclone,
|
||
IntegrationType.SHAREPOINT: _upload_sharepoint,
|
||
IntegrationType.ICLOUD: _upload_icloud,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Celery task
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@celery.task(base=UploadTaskWithRetry, bind=True)
|
||
def upload_to_user_integration(self, file_path: str, integration_id: int, file_id: int | None = None) -> dict[str, Any]:
|
||
"""Upload *file_path* to the destination described by the given UserIntegration record.
|
||
|
||
This task is dispatched once per active DESTINATION UserIntegration that
|
||
belongs to a document's owner. Credentials are decrypted at runtime so
|
||
they never travel across the Celery message bus in plaintext.
|
||
|
||
Args:
|
||
file_path: Absolute path to the processed document file.
|
||
integration_id: Primary key of the ``UserIntegration`` record.
|
||
file_id: Optional ``FileRecord.id`` used for progress logging.
|
||
|
||
Returns:
|
||
A dict with at least ``{"status": "Completed", ...}`` on success.
|
||
|
||
Raises:
|
||
FileNotFoundError: When *file_path* does not exist.
|
||
ValueError: When the integration record is not found or has missing config.
|
||
RuntimeError: When the underlying upload operation fails.
|
||
"""
|
||
task_id = self.request.id
|
||
filename = os.path.basename(file_path)
|
||
|
||
log_task_progress(
|
||
task_id,
|
||
f"upload_to_user_integration_{integration_id}",
|
||
"in_progress",
|
||
f"Uploading {filename} to integration {integration_id}",
|
||
file_id=file_id,
|
||
)
|
||
|
||
if not os.path.exists(file_path):
|
||
error_msg = f"File not found: {file_path}"
|
||
logger.error("[%s] %s", task_id, error_msg)
|
||
log_task_progress(
|
||
task_id, f"upload_to_user_integration_{integration_id}", "failure", error_msg, file_id=file_id
|
||
)
|
||
raise FileNotFoundError(error_msg)
|
||
|
||
with SessionLocal() as db:
|
||
integration: UserIntegration | None = (
|
||
db.query(UserIntegration).filter(UserIntegration.id == integration_id).first()
|
||
)
|
||
if integration is None:
|
||
error_msg = f"UserIntegration {integration_id} not found"
|
||
logger.error("[%s] %s", task_id, error_msg)
|
||
log_task_progress(
|
||
task_id, f"upload_to_user_integration_{integration_id}", "failure", error_msg, file_id=file_id
|
||
)
|
||
raise ValueError(error_msg)
|
||
|
||
itype = integration.integration_type
|
||
int_name = integration.name
|
||
owner_id = integration.owner_id
|
||
|
||
# Parse config (non-sensitive) and decrypt credentials (sensitive)
|
||
try:
|
||
cfg: dict[str, Any] = json.loads(integration.config) if integration.config else {}
|
||
except json.JSONDecodeError as exc:
|
||
raise ValueError(f"Integration {integration_id} has invalid JSON in config: {exc}") from exc
|
||
|
||
try:
|
||
raw_creds = decrypt_value(integration.credentials) if integration.credentials else None
|
||
creds: dict[str, Any] = json.loads(raw_creds) if raw_creds else {}
|
||
except json.JSONDecodeError as exc:
|
||
raise ValueError(f"Integration {integration_id} has invalid JSON in credentials: {exc}") from exc
|
||
|
||
handler = _UPLOAD_HANDLERS.get(itype)
|
||
if handler is None:
|
||
error_msg = f"No upload handler registered for integration type '{itype}' (integration {integration_id})"
|
||
logger.warning("[%s] %s", task_id, error_msg)
|
||
log_task_progress(
|
||
task_id, f"upload_to_user_integration_{integration_id}", "skipped", error_msg, file_id=file_id
|
||
)
|
||
return {"status": "Skipped", "reason": error_msg}
|
||
|
||
logger.info(
|
||
"[%s] Uploading %s via %s integration '%s' (id=%d, owner=%s)",
|
||
task_id,
|
||
filename,
|
||
itype,
|
||
int_name,
|
||
integration_id,
|
||
owner_id,
|
||
)
|
||
|
||
try:
|
||
result = handler(file_path, cfg, creds, task_id)
|
||
|
||
# Update last_used_at on success
|
||
with SessionLocal() as db:
|
||
integ = db.query(UserIntegration).filter(UserIntegration.id == integration_id).first()
|
||
if integ:
|
||
integ.last_used_at = datetime.now(timezone.utc)
|
||
integ.last_error = None
|
||
db.commit()
|
||
|
||
log_task_progress(
|
||
task_id,
|
||
f"upload_to_user_integration_{integration_id}",
|
||
"success",
|
||
f"Uploaded to {itype} '{int_name}': {filename}",
|
||
file_id=file_id,
|
||
)
|
||
return result
|
||
|
||
except Exception as exc:
|
||
error_msg = str(exc)[:_MAX_ERROR_LENGTH]
|
||
logger.error(
|
||
"[%s] Upload to integration %d (%s '%s') failed: %s",
|
||
task_id,
|
||
integration_id,
|
||
itype,
|
||
int_name,
|
||
error_msg,
|
||
)
|
||
|
||
# Persist error for operator visibility
|
||
try:
|
||
with SessionLocal() as db:
|
||
integ = db.query(UserIntegration).filter(UserIntegration.id == integration_id).first()
|
||
if integ:
|
||
integ.last_used_at = datetime.now(timezone.utc)
|
||
integ.last_error = error_msg
|
||
db.commit()
|
||
except Exception as db_exc: # noqa: BLE001
|
||
logger.warning("[%s] Could not persist last_error for integration %d: %s", task_id, integration_id, db_exc)
|
||
|
||
log_task_progress(
|
||
task_id,
|
||
f"upload_to_user_integration_{integration_id}",
|
||
"failure",
|
||
f"Upload to {itype} '{int_name}' failed: {error_msg}",
|
||
file_id=file_id,
|
||
)
|
||
raise
|