356 lines
14 KiB
Python
356 lines
14 KiB
Python
import os
|
|
import time # Import time for sleep
|
|
import mimetypes
|
|
from io import BytesIO
|
|
from httpx import (
|
|
Client,
|
|
Auth,
|
|
BasicAuth,
|
|
Headers,
|
|
Request,
|
|
Response,
|
|
HTTPStatusError,
|
|
) # Import HTTPStatusError
|
|
import logging
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def log_request(request: Request):
|
|
logger.info(
|
|
"Request event hook ****: %s %s - Waiting for content",
|
|
request.method,
|
|
request.url,
|
|
)
|
|
logger.info("Request body: %s", request.content)
|
|
logger.info("Headers: %s", request.headers)
|
|
|
|
|
|
def log_response(response: Response):
|
|
response.read() # Explicitly read the stream before accessing .text
|
|
logger.info("Response [%s] %s", response.status_code, response.text)
|
|
|
|
|
|
class NextcloudClient:
|
|
|
|
def __init__(self, base_url: str, username: str, auth: Auth | None = None):
|
|
self.username = username # Store username
|
|
self._client = Client(
|
|
base_url=base_url,
|
|
auth=auth,
|
|
event_hooks={"request": [log_request], "response": [log_response]},
|
|
)
|
|
|
|
@classmethod
|
|
def from_env(cls):
|
|
|
|
logger.info("Creating NC Client using env vars")
|
|
|
|
host = os.environ["NEXTCLOUD_HOST"]
|
|
username = os.environ["NEXTCLOUD_USERNAME"]
|
|
password = os.environ["NEXTCLOUD_PASSWORD"]
|
|
# Pass username to constructor
|
|
return cls(base_url=host, username=username, auth=BasicAuth(username, password))
|
|
|
|
def capabilities(self):
|
|
|
|
response = self._client.get(
|
|
"/ocs/v2.php/cloud/capabilities",
|
|
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
|
)
|
|
response.raise_for_status()
|
|
|
|
return response.json()
|
|
|
|
def notes_get_settings(self):
|
|
response = self._client.get("/apps/notes/api/v1/settings")
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
def notes_get_all(self):
|
|
response = self._client.get("/apps/notes/api/v1/notes")
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
def notes_get_note(self, *, note_id: int):
|
|
response = self._client.get(f"/apps/notes/api/v1/notes/{note_id}")
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
def notes_create_note(
|
|
self,
|
|
*,
|
|
title: str | None = None,
|
|
content: str | None = None,
|
|
category: str | None = None,
|
|
):
|
|
body = {}
|
|
if title:
|
|
body.update({"title": title})
|
|
if content:
|
|
body.update({"content": content})
|
|
if category:
|
|
body.update({"category": category})
|
|
|
|
response = self._client.post(
|
|
url="/apps/notes/api/v1/notes",
|
|
json=body,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
def notes_update_note(
|
|
self,
|
|
*,
|
|
note_id: int,
|
|
etag: str,
|
|
title: str | None = None,
|
|
content: str | None = None,
|
|
category: str | None = None,
|
|
):
|
|
# body = {"etag": etag} # Removed redundant line
|
|
body = {}
|
|
if title:
|
|
body.update({"title": title})
|
|
if content:
|
|
body.update({"content": content})
|
|
if category:
|
|
body.update({"category": category})
|
|
|
|
logger.info(
|
|
"Attempting to update note %s with etag %s. Body: %s",
|
|
note_id,
|
|
etag, # This was current_etag in the loop
|
|
body,
|
|
)
|
|
# Ensure conditional PUT using If-Match header is active
|
|
response = self._client.put(
|
|
url=f"/apps/notes/api/v1/notes/{note_id}",
|
|
json=body,
|
|
headers={"If-Match": f'"{etag}"'}, # This was current_etag in the loop
|
|
)
|
|
logger.info(
|
|
"Update response for note %s: Status %s, Headers %s",
|
|
note_id,
|
|
response.status_code,
|
|
response.headers,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
def notes_search_notes(self, *, query: str):
|
|
all_notes = self.notes_get_all()
|
|
search_results = []
|
|
query_lower = query.lower()
|
|
for note in all_notes:
|
|
title_lower = note.get("title", "").lower()
|
|
content_lower = note.get("content", "").lower()
|
|
if query_lower in title_lower or query_lower in content_lower:
|
|
search_results.append(
|
|
{
|
|
"id": note.get("id"),
|
|
"title": note.get("title"),
|
|
"category": note.get("category"),
|
|
"modified": note.get("modified"),
|
|
}
|
|
)
|
|
raise e
|
|
|
|
def delete_webdav_resource(self, *, path: str):
|
|
"""Delete a resource (file or directory) via WebDAV DELETE."""
|
|
# Ensure path ends with a slash if it's a directory
|
|
if not path.endswith('/'):
|
|
# This is a heuristic; a more robust solution would check resource type first
|
|
# but for the specific case of deleting the attachment directory, this is acceptable.
|
|
path_with_slash = f"{path}/"
|
|
else:
|
|
path_with_slash = path
|
|
|
|
webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}"
|
|
logger.info("Deleting WebDAV resource: %s", webdav_path)
|
|
|
|
headers = {"OCS-APIRequest": "true"}
|
|
try:
|
|
response = self._client.delete(webdav_path, headers=headers)
|
|
response.raise_for_status() # Raises for 4xx/5xx status codes
|
|
logger.info("Successfully deleted WebDAV resource '%s' (Status: %s)", webdav_path, response.status_code)
|
|
# DELETE typically returns 204 No Content on success
|
|
return {"status_code": response.status_code}
|
|
|
|
except HTTPStatusError as e:
|
|
logger.error(
|
|
"HTTP error deleting WebDAV resource '%s': %s",
|
|
webdav_path,
|
|
e,
|
|
)
|
|
# It's expected to get a 404 if the resource doesn't exist, which is fine.
|
|
# We only re-raise if it's not a 404.
|
|
if e.response.status_code != 404:
|
|
raise e
|
|
else:
|
|
logger.info("Resource '%s' not found, no deletion needed.", webdav_path)
|
|
return {"status_code": 404} # Indicate resource was not found
|
|
except Exception as e:
|
|
logger.error(
|
|
"Unexpected error deleting WebDAV resource '%s': %s",
|
|
webdav_path,
|
|
e,
|
|
)
|
|
raise e
|
|
|
|
def notes_delete_note(self, *, note_id: int):
|
|
# First delete the note through the Notes API
|
|
response = self._client.delete(f"/apps/notes/api/v1/notes/{note_id}")
|
|
response.raise_for_status()
|
|
json_response = response.json()
|
|
|
|
# Now, attempt to delete the associated attachments directory via WebDAV
|
|
# Add a trailing slash as suggested
|
|
attachment_dir_path = f"Notes/.attachments.{note_id}/"
|
|
logger.info(f"Attempting to delete attachment directory for note {note_id} via WebDAV: {attachment_dir_path}")
|
|
try:
|
|
self.delete_webdav_resource(path=attachment_dir_path)
|
|
logger.info(f"Successfully attempted to delete attachment directory for note {note_id}.")
|
|
except Exception as e:
|
|
# Log the error but don't re-raise, as note deletion itself was successful
|
|
logger.error(f"Failed to delete attachment directory for note {note_id}: {e}")
|
|
|
|
return json_response
|
|
|
|
# Removed incorrect get_note_attachment method that used Notes API
|
|
|
|
def _get_webdav_base_path(self) -> str:
|
|
"""Helper to get the base WebDAV path for the authenticated user."""
|
|
# Use the stored username
|
|
return f"/remote.php/dav/files/{self.username}"
|
|
|
|
def add_note_attachment(self, *, note_id: int, filename: str, content: bytes, mime_type: str | None = None):
|
|
"""Add/Update an attachment to a note via WebDAV PUT."""
|
|
# Attachments are stored in a hidden folder .attachments.{note_id} within the Notes folder
|
|
webdav_base = self._get_webdav_base_path()
|
|
attachment_path = f"{webdav_base}/Notes/.attachments.{note_id}/{filename}"
|
|
logger.info("Uploading attachment to WebDAV path: %s", attachment_path)
|
|
|
|
# Log current auth settings to diagnose the issue
|
|
logger.info("WebDAV auth settings - Username: %s, Auth Type: %s",
|
|
self.username, type(self._client.auth).__name__)
|
|
|
|
if not mime_type:
|
|
mime_type, _ = mimetypes.guess_type(filename)
|
|
if not mime_type:
|
|
mime_type = "application/octet-stream" # Default if guessing fails
|
|
|
|
headers = {"Content-Type": mime_type, "OCS-APIRequest": "true"}
|
|
try:
|
|
# First check if we can access WebDAV at all with current credentials
|
|
# by checking the Notes directory
|
|
notes_dir_path = f"{webdav_base}/Notes"
|
|
logger.info("Testing WebDAV access to Notes directory: %s", notes_dir_path)
|
|
|
|
# Log details of the auth being used by the client for this specific request
|
|
if self._client.auth:
|
|
auth_header = self._client.auth.auth_flow(self._client.build_request("GET", notes_dir_path)).__next__().headers.get("Authorization")
|
|
logger.info("Authorization header for PROPFIND (Notes dir): %s", auth_header if auth_header else "Not present or generated by auth flow")
|
|
else:
|
|
logger.info("No httpx.Auth object configured on the client for PROPFIND (Notes dir).")
|
|
|
|
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
|
logger.info("Headers for PROPFIND (Notes dir): %s", propfind_headers)
|
|
notes_dir_response = self._client.request("PROPFIND", notes_dir_path,
|
|
headers=propfind_headers)
|
|
|
|
if notes_dir_response.status_code == 401:
|
|
logger.error("WebDAV authentication failed for Notes directory. Please verify WebDAV permissions.")
|
|
raise HTTPStatusError(
|
|
f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}",
|
|
request=notes_dir_response.request,
|
|
response=notes_dir_response
|
|
)
|
|
elif notes_dir_response.status_code >= 400:
|
|
logger.error("Error accessing WebDAV Notes directory: %s", notes_dir_response.status_code)
|
|
notes_dir_response.raise_for_status()
|
|
else:
|
|
logger.info("Successfully accessed WebDAV Notes directory (Status: %s)",
|
|
notes_dir_response.status_code)
|
|
|
|
# Ensure the parent directory exists using MKCOL
|
|
parent_dir_path = f"{webdav_base}/Notes/.attachments.{note_id}"
|
|
logger.info("Creating attachments directory: %s", parent_dir_path)
|
|
mkcol_headers = {"OCS-APIRequest": "true"}
|
|
logger.info("Headers for MKCOL (Attachments dir): %s", mkcol_headers)
|
|
mkcol_response = self._client.request("MKCOL", parent_dir_path, headers=mkcol_headers)
|
|
# MKCOL should return 201 Created or 405 Method Not Allowed (if exists)
|
|
# We can ignore 405, but raise for other errors
|
|
if mkcol_response.status_code not in [201, 405]:
|
|
logger.warning(
|
|
"Unexpected status code %s when creating attachments directory",
|
|
mkcol_response.status_code
|
|
)
|
|
mkcol_response.raise_for_status()
|
|
else:
|
|
logger.info("Created/verified directory: %s (Status: %s)",
|
|
parent_dir_path, mkcol_response.status_code)
|
|
|
|
# Proceed with the PUT request
|
|
logger.info("Putting attachment file to: %s", attachment_path)
|
|
response = self._client.put(
|
|
attachment_path,
|
|
content=content,
|
|
headers=headers
|
|
)
|
|
response.raise_for_status() # Raises for 4xx/5xx status codes
|
|
logger.info("Successfully uploaded attachment '%s' to note %s (Status: %s)", filename, note_id, response.status_code)
|
|
# PUT typically returns 201 Created or 204 No Content on success
|
|
return {"status_code": response.status_code} # Return status or relevant info
|
|
|
|
except HTTPStatusError as e:
|
|
logger.error(
|
|
"HTTP error uploading attachment '%s' to note %s: %s",
|
|
filename,
|
|
note_id,
|
|
e,
|
|
)
|
|
raise e
|
|
except Exception as e:
|
|
logger.error(
|
|
"Unexpected error uploading attachment '%s' to note %s: %s",
|
|
filename,
|
|
note_id,
|
|
e,
|
|
)
|
|
raise e
|
|
|
|
def get_note_attachment(self, *, note_id: int, filename: str):
|
|
"""Fetch a specific attachment from a note via WebDAV GET."""
|
|
webdav_base = self._get_webdav_base_path()
|
|
attachment_path = f"{webdav_base}/Notes/.attachments.{note_id}/{filename}"
|
|
logger.info("Fetching attachment from WebDAV path: %s", attachment_path)
|
|
|
|
try:
|
|
response = self._client.get(attachment_path)
|
|
response.raise_for_status()
|
|
|
|
content = response.content
|
|
mime_type = response.headers.get("content-type", "application/octet-stream")
|
|
|
|
logger.info("Successfully fetched attachment '%s' (%s, %d bytes)", filename, mime_type, len(content))
|
|
return content, mime_type
|
|
|
|
except HTTPStatusError as e:
|
|
logger.error(
|
|
"HTTP error fetching attachment '%s' for note %s: %s",
|
|
filename,
|
|
note_id,
|
|
e,
|
|
)
|
|
raise e
|
|
except Exception as e:
|
|
logger.error(
|
|
"Unexpected error fetching attachment '%s' for note %s: %s",
|
|
filename,
|
|
note_id,
|
|
e,
|
|
)
|
|
raise e
|