b85fc1d277
Add Microsoft SharePoint Online as a storage provider using the Microsoft Graph API. Includes OAuth2 authentication via MSAL, site/drive resolution, chunked upload sessions, and metadata sync. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
840 lines
33 KiB
Python
840 lines
33 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}
|
|
|
|
|
|
# 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,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|