Skip to content

Commit 2d21470

Browse files
committed
new plugins
1 parent 7e63d00 commit 2d21470

112 files changed

Lines changed: 12102 additions & 495 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎aird/cloud/__init__.py‎

Lines changed: 151 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,25 @@
2323
"OneDriveProvider",
2424
"encode_identifier",
2525
"decode_identifier",
26+
"split_drive_path",
2627
]
2728

2829

30+
def split_drive_path(path: str) -> list[str]:
31+
"""Split a OneDrive path into safe folder/file segments."""
32+
out: list[str] = []
33+
for raw in str(path or "").replace("\\", "/").split("/"):
34+
name = raw.strip()
35+
if not name or name in {".", ".."}:
36+
continue
37+
if "/" in name or "\\" in name or "\x00" in name:
38+
continue
39+
out.append(name[:255])
40+
if len(out) >= 32:
41+
break
42+
return out
43+
44+
2945
class CloudProviderError(Exception):
3046
"""Raised when a cloud provider encounters an operational error."""
3147

@@ -119,6 +135,7 @@ def upload_file(
119135
parent_id: Optional[str] = None,
120136
size: Optional[int] = None,
121137
content_type: Optional[str] = None,
138+
conflict: str = "rename",
122139
) -> CloudFile: # pragma: no cover - interface
123140
raise NotImplementedError
124141

@@ -402,6 +419,7 @@ def upload_file(
402419
parent_id: Optional[str] = None,
403420
size: Optional[int] = None,
404421
content_type: Optional[str] = None,
422+
conflict: str = "rename",
405423
) -> CloudFile:
406424
if not name:
407425
raise CloudProviderError("A file name is required for Google Drive uploads")
@@ -490,6 +508,104 @@ def list_files(self, folder_id: str | None = None) -> List[CloudFile]:
490508
)
491509
return files
492510

511+
def _item_path_url(self, path: str, extra: str = "") -> str:
512+
parts = split_drive_path(path)
513+
if not parts:
514+
return (
515+
f"{self._base_url}/root/{extra.lstrip('/')}"
516+
if extra
517+
else f"{self._base_url}/root"
518+
)
519+
encoded = "/".join(quote(part, safe="") for part in parts)
520+
if extra:
521+
return f"{self._base_url}/root:/{encoded}:/{extra.lstrip('/')}"
522+
return f"{self._base_url}/root:/{encoded}"
523+
524+
def _file_from_payload(self, payload: dict, *, default_name: str = "Unnamed") -> CloudFile:
525+
return CloudFile(
526+
id=payload.get("id", ""),
527+
name=payload.get("name", default_name),
528+
is_dir="folder" in payload,
529+
size=_safe_int(payload.get("size")),
530+
modified=payload.get("lastModifiedDateTime"),
531+
)
532+
533+
def get_item_by_path(self, path: str) -> Optional[CloudFile]:
534+
parts = split_drive_path(path)
535+
if not parts:
536+
return CloudFile(id="root", name="root", is_dir=True)
537+
try:
538+
response = requests.get(
539+
self._item_path_url(path),
540+
headers=self._headers(),
541+
timeout=30,
542+
)
543+
except requests.RequestException as exc:
544+
raise CloudProviderError(f"OneDrive request failed: {exc}") from exc
545+
if response.status_code == 404:
546+
return None
547+
if response.status_code != 200:
548+
raise CloudProviderError(
549+
f"OneDrive item lookup failed ({response.status_code}): {response.text[:200]}"
550+
)
551+
return self._file_from_payload(response.json())
552+
553+
def ensure_folder_path(self, path: str) -> CloudFile:
554+
parts = split_drive_path(path)
555+
if not parts:
556+
return CloudFile(id="root", name="root", is_dir=True)
557+
existing = self.get_item_by_path("/".join(parts))
558+
if existing is not None:
559+
if not existing.is_dir:
560+
raise CloudProviderError("OneDrive path exists and is not a folder")
561+
return existing
562+
parent_id: Optional[str] = None
563+
current: list[str] = []
564+
last = CloudFile(id="root", name="root", is_dir=True)
565+
for name in parts:
566+
current.append(name)
567+
found = self.get_item_by_path("/".join(current))
568+
if found is not None:
569+
if not found.is_dir:
570+
raise CloudProviderError("OneDrive path exists and is not a folder")
571+
last = found
572+
parent_id = found.id
573+
continue
574+
last = self.ensure_folder(name, parent_id=parent_id)
575+
parent_id = last.id
576+
return last
577+
578+
def ensure_folder(self, name: str, parent_id: Optional[str] = None) -> CloudFile:
579+
for item in self.list_files(parent_id):
580+
if item.is_dir and item.name == name:
581+
return item
582+
if not parent_id or parent_id == "root":
583+
url = f"{self._base_url}/root/children"
584+
else:
585+
url = f"{self._base_url}/items/{parent_id}/children"
586+
try:
587+
response = requests.post(
588+
url,
589+
headers=self._headers(),
590+
json={
591+
"name": name,
592+
"folder": {},
593+
"@microsoft.graph.conflictBehavior": "fail",
594+
},
595+
timeout=30,
596+
)
597+
except requests.RequestException as exc:
598+
raise CloudProviderError(f"OneDrive request failed: {exc}") from exc
599+
if response.status_code in (200, 201):
600+
return self._file_from_payload(response.json(), default_name=name)
601+
if response.status_code == 409:
602+
for item in self.list_files(parent_id):
603+
if item.is_dir and item.name == name:
604+
return item
605+
raise CloudProviderError(
606+
f"OneDrive folder create failed ({response.status_code}): {response.text[:200]}"
607+
)
608+
493609
def download_file(self, file_id: str) -> CloudDownload:
494610
try:
495611
meta_resp = requests.get(
@@ -559,17 +675,10 @@ def _upload_simple_onedrive(
559675
f"OneDrive upload failed ({response.status_code}): {response.text[:200]}"
560676
)
561677

562-
payload = response.json()
563-
return CloudFile(
564-
id=payload.get("id", ""),
565-
name=payload.get("name", name),
566-
is_dir="folder" in payload,
567-
size=_safe_int(payload.get("size")),
568-
modified=payload.get("lastModifiedDateTime"),
569-
)
678+
return self._file_from_payload(response.json(), default_name=name)
570679

571680
def _upload_chunked_onedrive(
572-
self, stream, name, parent_id, mime_type, size
681+
self, stream, name, parent_id, mime_type, size, conflict: str = "rename"
573682
) -> CloudFile:
574683
safe_name = quote(name, safe="")
575684
if not parent_id or parent_id == "root":
@@ -584,7 +693,11 @@ def _upload_chunked_onedrive(
584693
session_url,
585694
headers=self._headers(),
586695
json={
587-
"item": {"@microsoft.graph.conflictBehavior": "rename"}
696+
"item": {
697+
"@microsoft.graph.conflictBehavior": conflict
698+
if conflict in {"rename", "replace", "fail"}
699+
else "rename"
700+
}
588701
},
589702
timeout=60,
590703
)
@@ -629,14 +742,7 @@ def _upload_chunked_onedrive(
629742
) from exc
630743

631744
if upload_resp.status_code in (200, 201):
632-
payload = upload_resp.json()
633-
return CloudFile(
634-
id=payload.get("id", ""),
635-
name=payload.get("name", name),
636-
is_dir="folder" in payload,
637-
size=_safe_int(payload.get("size")),
638-
modified=payload.get("lastModifiedDateTime"),
639-
)
745+
return self._file_from_payload(upload_resp.json(), default_name=name)
640746

641747
if upload_resp.status_code in (202, 204):
642748
offset = end + 1
@@ -656,6 +762,7 @@ def upload_file(
656762
parent_id: Optional[str] = None,
657763
size: Optional[int] = None,
658764
content_type: Optional[str] = None,
765+
conflict: str = "rename",
659766
) -> CloudFile:
660767
if not name:
661768
raise CloudProviderError("A file name is required for OneDrive uploads")
@@ -677,7 +784,32 @@ def upload_file(
677784
return self._upload_simple_onedrive(
678785
stream, name, parent_id, headers, mime_type
679786
)
680-
return self._upload_chunked_onedrive(stream, name, parent_id, mime_type, size)
787+
return self._upload_chunked_onedrive(
788+
stream, name, parent_id, mime_type, size, conflict=conflict
789+
)
790+
791+
def upload_file_at_path(
792+
self,
793+
stream: BinaryIO,
794+
*,
795+
drive_path: str,
796+
size: Optional[int] = None,
797+
content_type: Optional[str] = None,
798+
conflict: str = "replace",
799+
) -> CloudFile:
800+
parts = split_drive_path(drive_path)
801+
if not parts:
802+
raise CloudProviderError("A file path is required for OneDrive uploads")
803+
parent = self.ensure_folder_path("/".join(parts[:-1])) if len(parts) > 1 else None
804+
parent_id = None if parent is None or parent.id in ("", "root") else parent.id
805+
return self.upload_file(
806+
stream,
807+
name=parts[-1],
808+
parent_id=parent_id,
809+
size=size,
810+
content_type=content_type,
811+
conflict=conflict,
812+
)
681813

682814

683815
def encode_identifier(identifier: str) -> str:

‎aird/constants/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,14 @@
4949
"webdav_server": False,
5050
"direct_messages": False,
5151
"gitlab_integration": False,
52+
"onedrive_backup": False,
53+
"onedrive_browser": False,
5254
"transfer_sendfile": True,
5355
}
5456

5557
CHAT_SHARE_FOLDER = ".aird-shares"
5658
CHAT_STORE_FOLDER = ".aird-chats"
59+
AIRD_META_FOLDER = ".aird"
5760

5861
# WebSocket connection configuration
5962
WEBSOCKET_CONFIG = {

‎aird/constants/admin.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
URL_ADMIN = "/admin"
1111
URL_ADMIN_LOGIN = "/admin/login"
1212
URL_ADMIN_USERS = "/admin/users"
13+
URL_ADMIN_PLUGINS = "/admin/plugins"
14+
URL_ADMIN_FEATURE_FLAGS = "/admin/api/feature-flags"
1315
URL_ADMIN_LDAP = "/admin/ldap"
1416
URL_ADMIN_NETWORK_SHARES = "/admin/network-shares"
17+
URL_ADMIN_PATHS = "/admin/paths"
1518
# Network share error query params (URL-encoded)
1619
ERR_DB_UNAVAILABLE = "Database+unavailable"
1720
ERR_ALL_FIELDS_REQUIRED = "All+fields+are+required"

‎aird/constants/input_limits.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@
7777
GITLAB_PROJECT_PATH_MAX_LEN = 512
7878
GITLAB_HOST_MAX_LEN = 256
7979
GITLAB_TOKEN_MAX_LEN = 16 * 1024
80+
ONEDRIVE_TOKEN_MAX_LEN = 16 * 1024
81+
ONEDRIVE_ROOT_PATH_MAX_LEN = 512
82+
PLUGIN_USERNAME_MAX_LEN = 128
83+
PLUGIN_MAX_USERNAMES = 500
8084
GITLAB_BOARD_ID_MAX = 10_000_000
8185

8286
# Chat composer attachments in one message

0 commit comments

Comments
 (0)