kcfinder-client 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kcfinder_client/__init__.py +42 -0
- kcfinder_client/_core.py +177 -0
- kcfinder_client/async_client.py +214 -0
- kcfinder_client/auth.py +160 -0
- kcfinder_client/client.py +212 -0
- kcfinder_client/exceptions.py +96 -0
- kcfinder_client/models.py +36 -0
- kcfinder_client/sync.py +93 -0
- kcfinder_client-0.2.0.dist-info/METADATA +175 -0
- kcfinder_client-0.2.0.dist-info/RECORD +12 -0
- kcfinder_client-0.2.0.dist-info/WHEEL +4 -0
- kcfinder_client-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Python client library for KCFinder file manager."""
|
|
2
|
+
|
|
3
|
+
from kcfinder_client.async_client import AsyncKCFinderClient
|
|
4
|
+
from kcfinder_client.auth import (
|
|
5
|
+
BaseAuth,
|
|
6
|
+
HarmonySiteAuth,
|
|
7
|
+
SessionAuth,
|
|
8
|
+
harmonysite_auth_from_env,
|
|
9
|
+
)
|
|
10
|
+
from kcfinder_client.client import KCFinderClient
|
|
11
|
+
from kcfinder_client.exceptions import (
|
|
12
|
+
ActionError,
|
|
13
|
+
AuthError,
|
|
14
|
+
DirectoryOperationError,
|
|
15
|
+
FileOperationError,
|
|
16
|
+
KCFinderError,
|
|
17
|
+
PermissionDeniedError,
|
|
18
|
+
UploadError,
|
|
19
|
+
)
|
|
20
|
+
from kcfinder_client.models import DirTree, FileInfo, SyncResult
|
|
21
|
+
from kcfinder_client.sync import SyncManager, SyncManagerSync
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ActionError",
|
|
25
|
+
"AsyncKCFinderClient",
|
|
26
|
+
"AuthError",
|
|
27
|
+
"BaseAuth",
|
|
28
|
+
"DirTree",
|
|
29
|
+
"DirectoryOperationError",
|
|
30
|
+
"FileInfo",
|
|
31
|
+
"FileOperationError",
|
|
32
|
+
"HarmonySiteAuth",
|
|
33
|
+
"KCFinderClient",
|
|
34
|
+
"KCFinderError",
|
|
35
|
+
"PermissionDeniedError",
|
|
36
|
+
"SessionAuth",
|
|
37
|
+
"SyncManager",
|
|
38
|
+
"SyncManagerSync",
|
|
39
|
+
"SyncResult",
|
|
40
|
+
"UploadError",
|
|
41
|
+
"harmonysite_auth_from_env",
|
|
42
|
+
]
|
kcfinder_client/_core.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Shared request-building and response-parsing logic for KCFinder clients."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
from kcfinder_client.exceptions import classify_error
|
|
8
|
+
from kcfinder_client.models import DirTree, FileInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def prefix_dir(file_type: str, dir: str) -> str:
|
|
12
|
+
"""Build a type-prefixed directory path for KCFinder.
|
|
13
|
+
|
|
14
|
+
KCFinder expects all ``dir`` parameters to start with the file type
|
|
15
|
+
(e.g., ``images/subfolder``). The PHP constructor validates this prefix
|
|
16
|
+
via ``checkInputDir`` and strips it before resolving the filesystem path.
|
|
17
|
+
"""
|
|
18
|
+
if dir:
|
|
19
|
+
return f"{file_type}/{dir}"
|
|
20
|
+
return file_type
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def prefix_file_paths(file_type: str, paths: list[str]) -> list[str]:
|
|
24
|
+
"""Prepend the file type to each path for bulk clipboard operations.
|
|
25
|
+
|
|
26
|
+
Bulk actions (cp_cbd, mv_cbd, rm_cbd) expect each file entry to be a
|
|
27
|
+
type-prefixed path like ``images/subfolder/photo.jpg``.
|
|
28
|
+
"""
|
|
29
|
+
return [f"{file_type}/{p}" for p in paths]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_action_url(
|
|
33
|
+
browse_url: str,
|
|
34
|
+
action: str,
|
|
35
|
+
file_type: str | None,
|
|
36
|
+
extra_params: dict[str, str] | None = None,
|
|
37
|
+
) -> str:
|
|
38
|
+
"""Build the full URL for a KCFinder action."""
|
|
39
|
+
params: dict[str, str] = {}
|
|
40
|
+
if extra_params:
|
|
41
|
+
params.update(extra_params)
|
|
42
|
+
params["act"] = action
|
|
43
|
+
if file_type is not None:
|
|
44
|
+
params["type"] = file_type
|
|
45
|
+
return f"{browse_url}?{urlencode(params)}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_headers(referer: str) -> dict[str, str]:
|
|
49
|
+
"""Build the required headers for a KCFinder request."""
|
|
50
|
+
return {
|
|
51
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
52
|
+
"Referer": referer,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_form_data(
|
|
57
|
+
*,
|
|
58
|
+
dir: str | None = None,
|
|
59
|
+
file: str | None = None,
|
|
60
|
+
new_name: str | None = None,
|
|
61
|
+
new_dir: str | None = None,
|
|
62
|
+
files: list[str] | None = None,
|
|
63
|
+
) -> dict[str, str | list[str]]:
|
|
64
|
+
"""Build the form data dict for a KCFinder action."""
|
|
65
|
+
data: dict[str, str | list[str]] = {}
|
|
66
|
+
if dir is not None:
|
|
67
|
+
data["dir"] = dir
|
|
68
|
+
if file is not None:
|
|
69
|
+
data["file"] = file
|
|
70
|
+
if new_name is not None:
|
|
71
|
+
data["newName"] = new_name
|
|
72
|
+
if new_dir is not None:
|
|
73
|
+
data["newDir"] = new_dir
|
|
74
|
+
if files is not None:
|
|
75
|
+
data["files[]"] = files
|
|
76
|
+
return data
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_file_list(raw: dict) -> list[FileInfo]:
|
|
80
|
+
"""Parse a file list from a KCFinder response into FileInfo objects."""
|
|
81
|
+
writable = raw.get("dirWritable", False)
|
|
82
|
+
return [
|
|
83
|
+
FileInfo(
|
|
84
|
+
name=f["name"],
|
|
85
|
+
size=f["size"],
|
|
86
|
+
mtime=datetime.fromtimestamp(f["mtime"], tz=timezone.utc), # noqa: UP017
|
|
87
|
+
is_writable=f.get("writable", writable),
|
|
88
|
+
)
|
|
89
|
+
for f in raw.get("files", [])
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_dir_tree(raw: dict) -> DirTree:
|
|
94
|
+
"""Parse a KCFinder init response into a DirTree.
|
|
95
|
+
|
|
96
|
+
The init response wraps the tree in a "tree" key at the top level.
|
|
97
|
+
Files are siblings of "tree", not nested under it.
|
|
98
|
+
"""
|
|
99
|
+
tree = raw.get("tree", raw)
|
|
100
|
+
files = parse_file_list(raw) if "files" in raw else []
|
|
101
|
+
return _parse_tree_node(tree, files)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_tree_node(node: dict, files: list[FileInfo] | None = None) -> DirTree:
|
|
105
|
+
"""Parse a single tree node recursively."""
|
|
106
|
+
return DirTree(
|
|
107
|
+
name=node["name"],
|
|
108
|
+
is_writable=node.get("writable", False),
|
|
109
|
+
has_subdirs=node.get("hasDirs", False),
|
|
110
|
+
children=[_parse_tree_node(child) for child in node.get("dirs", [])],
|
|
111
|
+
files=files if files is not None else [],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def check_upload_response(response_text: str) -> None:
|
|
116
|
+
"""Check an upload response for errors.
|
|
117
|
+
|
|
118
|
+
Upload responses differ from other actions: success returns the
|
|
119
|
+
uploaded filename prefixed with "/" (e.g., "/photo.jpg"). Errors
|
|
120
|
+
return "filename: error message". Multiple files produce one line
|
|
121
|
+
per file.
|
|
122
|
+
"""
|
|
123
|
+
for line in response_text.strip().splitlines():
|
|
124
|
+
line = line.strip()
|
|
125
|
+
if not line or line.startswith("/"):
|
|
126
|
+
continue # success — uploaded filename
|
|
127
|
+
raise classify_error(action="upload", message=line)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def check_action_error(action: str, response_body: str | dict) -> None:
|
|
131
|
+
"""Check a KCFinder response body for errors and raise if found.
|
|
132
|
+
|
|
133
|
+
KCFinder returns HTTP 200 even on errors. Successful mutating actions
|
|
134
|
+
return the string ``{}`` (PHP echoes ``{}`` when the handler returns
|
|
135
|
+
``true``). Query actions return valid JSON. Errors come as plain
|
|
136
|
+
strings or ``{"error": "message"}`` dicts. Bulk clipboard actions
|
|
137
|
+
return ``{"error": ["msg1", "msg2"]}`` with a list of per-file errors.
|
|
138
|
+
"""
|
|
139
|
+
if isinstance(response_body, dict):
|
|
140
|
+
if "error" in response_body:
|
|
141
|
+
err = response_body["error"]
|
|
142
|
+
msg = "; ".join(err) if isinstance(err, list) else str(err)
|
|
143
|
+
raise classify_error(action=action, message=msg)
|
|
144
|
+
return # empty dict or success dict without "error" key
|
|
145
|
+
if isinstance(response_body, str):
|
|
146
|
+
stripped = response_body.strip()
|
|
147
|
+
if stripped == "" or stripped == "{}":
|
|
148
|
+
return
|
|
149
|
+
# Try to parse as JSON — some actions return JSON error strings
|
|
150
|
+
try:
|
|
151
|
+
parsed = json.loads(stripped)
|
|
152
|
+
if isinstance(parsed, dict) and "error" in parsed:
|
|
153
|
+
err = parsed["error"]
|
|
154
|
+
msg = "; ".join(err) if isinstance(err, list) else str(err)
|
|
155
|
+
raise classify_error(action=action, message=msg)
|
|
156
|
+
return # valid JSON without "error" key
|
|
157
|
+
except json.JSONDecodeError:
|
|
158
|
+
pass
|
|
159
|
+
raise classify_error(action=action, message=stripped)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse_expand_response(raw: dict) -> list[DirTree]:
|
|
163
|
+
"""Parse a KCFinder expand response into a list of DirTree nodes.
|
|
164
|
+
|
|
165
|
+
The expand action returns ``{"dirs": [{name, writable, hasDirs, ...}]}``.
|
|
166
|
+
Each entry describes a direct subdirectory (no nested children).
|
|
167
|
+
"""
|
|
168
|
+
return [
|
|
169
|
+
DirTree(
|
|
170
|
+
name=d["name"],
|
|
171
|
+
is_writable=d.get("writable", False),
|
|
172
|
+
has_subdirs=d.get("hasDirs", False),
|
|
173
|
+
children=[],
|
|
174
|
+
files=[],
|
|
175
|
+
)
|
|
176
|
+
for d in raw.get("dirs", [])
|
|
177
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Async client for KCFinder file manager operations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from kcfinder_client._core import (
|
|
10
|
+
build_action_url,
|
|
11
|
+
build_form_data,
|
|
12
|
+
build_headers,
|
|
13
|
+
check_action_error,
|
|
14
|
+
check_upload_response,
|
|
15
|
+
parse_dir_tree,
|
|
16
|
+
parse_expand_response,
|
|
17
|
+
parse_file_list,
|
|
18
|
+
prefix_dir,
|
|
19
|
+
prefix_file_paths,
|
|
20
|
+
)
|
|
21
|
+
from kcfinder_client.auth import BaseAuth
|
|
22
|
+
from kcfinder_client.models import DirTree, FileInfo
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncKCFinderClient:
|
|
26
|
+
"""Async client for KCFinder file manager operations."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
browse_url: str,
|
|
31
|
+
auth: BaseAuth,
|
|
32
|
+
file_type: str = "images",
|
|
33
|
+
) -> None:
|
|
34
|
+
self._browse_url = browse_url
|
|
35
|
+
self._auth = auth
|
|
36
|
+
self._file_type = file_type
|
|
37
|
+
self._client: httpx.AsyncClient | None = None
|
|
38
|
+
|
|
39
|
+
async def __aenter__(self) -> AsyncKCFinderClient:
|
|
40
|
+
self._client = httpx.AsyncClient()
|
|
41
|
+
await self._auth.authenticate(self._client)
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
async def __aexit__(
|
|
45
|
+
self,
|
|
46
|
+
exc_type: type[BaseException] | None,
|
|
47
|
+
exc_val: BaseException | None,
|
|
48
|
+
exc_tb: TracebackType | None,
|
|
49
|
+
) -> None:
|
|
50
|
+
if self._client:
|
|
51
|
+
await self._client.aclose()
|
|
52
|
+
|
|
53
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
54
|
+
"""Return the active HTTP client, raising if not initialized."""
|
|
55
|
+
if self._client is None:
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
"Client not initialized. Use 'async with' context manager."
|
|
58
|
+
)
|
|
59
|
+
return self._client
|
|
60
|
+
|
|
61
|
+
async def _post(self, action: str, data: dict) -> httpx.Response:
|
|
62
|
+
"""Send a POST request to KCFinder."""
|
|
63
|
+
url = build_action_url(
|
|
64
|
+
self._browse_url, action, self._file_type, self._auth.get_query_params()
|
|
65
|
+
)
|
|
66
|
+
headers = build_headers(self._auth.get_referer())
|
|
67
|
+
return await self._get_client().post(url, data=data, headers=headers)
|
|
68
|
+
|
|
69
|
+
async def list_files(self, dir: str = "") -> list[FileInfo]:
|
|
70
|
+
"""List files in a directory (root by default)."""
|
|
71
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir))
|
|
72
|
+
response = await self._post("chDir", data)
|
|
73
|
+
return parse_file_list(response.json())
|
|
74
|
+
|
|
75
|
+
async def upload(self, dir: str, files: Path | list[Path]) -> None:
|
|
76
|
+
"""Upload one or more files to a directory."""
|
|
77
|
+
if isinstance(files, Path):
|
|
78
|
+
files = [files]
|
|
79
|
+
url = build_action_url(
|
|
80
|
+
self._browse_url, "upload", self._file_type, self._auth.get_query_params()
|
|
81
|
+
)
|
|
82
|
+
url += "&" + urlencode({"dir": prefix_dir(self._file_type, dir)})
|
|
83
|
+
headers = build_headers(self._auth.get_referer())
|
|
84
|
+
upload_files = [("upload[]", (f.name, f.read_bytes())) for f in files]
|
|
85
|
+
response = await self._get_client().post(
|
|
86
|
+
url,
|
|
87
|
+
files=upload_files,
|
|
88
|
+
headers=headers,
|
|
89
|
+
)
|
|
90
|
+
check_upload_response(response.text)
|
|
91
|
+
|
|
92
|
+
async def delete(self, dir: str, file: str) -> None:
|
|
93
|
+
"""Delete a file."""
|
|
94
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir), file=file)
|
|
95
|
+
response = await self._post("delete", data)
|
|
96
|
+
check_action_error("delete", response.text)
|
|
97
|
+
|
|
98
|
+
async def rename(self, dir: str, file: str, new_name: str) -> None:
|
|
99
|
+
"""Rename a file."""
|
|
100
|
+
data = build_form_data(
|
|
101
|
+
dir=prefix_dir(self._file_type, dir),
|
|
102
|
+
file=file,
|
|
103
|
+
new_name=new_name,
|
|
104
|
+
)
|
|
105
|
+
response = await self._post("rename", data)
|
|
106
|
+
check_action_error("rename", response.text)
|
|
107
|
+
|
|
108
|
+
async def download(self, dir: str, file: str) -> bytes:
|
|
109
|
+
"""Download a file and return its content as bytes."""
|
|
110
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir), file=file)
|
|
111
|
+
response = await self._post("download", data)
|
|
112
|
+
return response.content
|
|
113
|
+
|
|
114
|
+
async def get_thumbnail(self, dir: str, file: str) -> bytes:
|
|
115
|
+
"""Get the thumbnail for a file as PNG bytes.
|
|
116
|
+
|
|
117
|
+
Thumbnails are served via GET parameters (used as ``<img src>``
|
|
118
|
+
in the browser UI), not POST form data.
|
|
119
|
+
"""
|
|
120
|
+
params = {
|
|
121
|
+
**self._auth.get_query_params(),
|
|
122
|
+
"act": "thumb",
|
|
123
|
+
"type": self._file_type,
|
|
124
|
+
"dir": prefix_dir(self._file_type, dir),
|
|
125
|
+
"file": file,
|
|
126
|
+
}
|
|
127
|
+
url = f"{self._browse_url}?{urlencode(params)}"
|
|
128
|
+
headers = build_headers(self._auth.get_referer())
|
|
129
|
+
response = await self._get_client().get(url, headers=headers)
|
|
130
|
+
return response.content
|
|
131
|
+
|
|
132
|
+
async def get_tree(self) -> DirTree:
|
|
133
|
+
"""Initialize the browser view and return the root directory node."""
|
|
134
|
+
url = build_action_url(
|
|
135
|
+
self._browse_url, "init", self._file_type, self._auth.get_query_params()
|
|
136
|
+
)
|
|
137
|
+
headers = build_headers(self._auth.get_referer())
|
|
138
|
+
response = await self._get_client().post(url, headers=headers)
|
|
139
|
+
return parse_dir_tree(response.json())
|
|
140
|
+
|
|
141
|
+
async def expand(self, dir: str = "") -> list[DirTree]:
|
|
142
|
+
"""Get subdirectory info for a directory (root by default)."""
|
|
143
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir))
|
|
144
|
+
response = await self._post("expand", data)
|
|
145
|
+
return parse_expand_response(response.json())
|
|
146
|
+
|
|
147
|
+
async def create_dir(self, dir: str, new_dir: str) -> None:
|
|
148
|
+
"""Create a new subdirectory."""
|
|
149
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir), new_dir=new_dir)
|
|
150
|
+
response = await self._post("newDir", data)
|
|
151
|
+
check_action_error("newDir", response.text)
|
|
152
|
+
|
|
153
|
+
async def rename_dir(self, dir: str, new_name: str) -> None:
|
|
154
|
+
"""Rename a directory."""
|
|
155
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir), new_name=new_name)
|
|
156
|
+
response = await self._post("renameDir", data)
|
|
157
|
+
check_action_error("renameDir", response.text)
|
|
158
|
+
|
|
159
|
+
async def delete_dir(self, dir: str) -> None:
|
|
160
|
+
"""Delete a directory and all its contents recursively.
|
|
161
|
+
|
|
162
|
+
Removes all files and subdirectories within the target directory
|
|
163
|
+
before removing the directory itself.
|
|
164
|
+
"""
|
|
165
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir))
|
|
166
|
+
response = await self._post("deleteDir", data)
|
|
167
|
+
check_action_error("deleteDir", response.text)
|
|
168
|
+
|
|
169
|
+
async def download_dir(self, dir: str) -> bytes:
|
|
170
|
+
"""Download a directory as a ZIP archive."""
|
|
171
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir))
|
|
172
|
+
response = await self._post("downloadDir", data)
|
|
173
|
+
return response.content
|
|
174
|
+
|
|
175
|
+
async def _bulk_action(
|
|
176
|
+
self, action: str, files: list[str], dest: str | None = None
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Shared logic for bulk copy/move/delete actions.
|
|
179
|
+
|
|
180
|
+
File paths are automatically prefixed with the file type (e.g.,
|
|
181
|
+
``images/subdir/file.jpg``) as required by the KCFinder protocol.
|
|
182
|
+
"""
|
|
183
|
+
prefixed = prefix_file_paths(self._file_type, files)
|
|
184
|
+
data = build_form_data(files=prefixed)
|
|
185
|
+
if dest is not None:
|
|
186
|
+
data["dir"] = prefix_dir(self._file_type, dest)
|
|
187
|
+
response = await self._post(action, data)
|
|
188
|
+
check_action_error(action, response.text)
|
|
189
|
+
|
|
190
|
+
async def copy(self, files: str | list[str], dest: str) -> None:
|
|
191
|
+
"""Copy files to a destination directory."""
|
|
192
|
+
if isinstance(files, str):
|
|
193
|
+
files = [files]
|
|
194
|
+
await self._bulk_action("cp_cbd", files, dest)
|
|
195
|
+
|
|
196
|
+
async def move(self, files: str | list[str], dest: str) -> None:
|
|
197
|
+
"""Move files to a destination directory."""
|
|
198
|
+
if isinstance(files, str):
|
|
199
|
+
files = [files]
|
|
200
|
+
await self._bulk_action("mv_cbd", files, dest)
|
|
201
|
+
|
|
202
|
+
async def bulk_delete(self, files: list[str]) -> None:
|
|
203
|
+
"""Delete multiple files."""
|
|
204
|
+
await self._bulk_action("rm_cbd", files)
|
|
205
|
+
|
|
206
|
+
async def download_selected(self, dir: str, files: list[str]) -> bytes:
|
|
207
|
+
"""Download selected files as a ZIP archive.
|
|
208
|
+
|
|
209
|
+
Unlike bulk clipboard operations, ``files`` here are plain
|
|
210
|
+
filenames (basenames) within the specified directory.
|
|
211
|
+
"""
|
|
212
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir), files=files)
|
|
213
|
+
response = await self._post("downloadSelected", data)
|
|
214
|
+
return response.content
|
kcfinder_client/auth.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from urllib.parse import urlencode
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from kcfinder_client.exceptions import AuthError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseAuth(ABC):
|
|
11
|
+
"""Base class for KCFinder authentication strategies."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
async def authenticate(self, session: httpx.AsyncClient) -> None:
|
|
15
|
+
"""Authenticate an async HTTP session."""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def authenticate_sync(self, session: httpx.Client) -> None:
|
|
19
|
+
"""Authenticate a sync HTTP session."""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def get_referer(self) -> str:
|
|
23
|
+
"""Return the Referer URL required for KCFinder requests."""
|
|
24
|
+
|
|
25
|
+
def get_query_params(self) -> dict[str, str]:
|
|
26
|
+
"""Return extra query params to include on every KCFinder request.
|
|
27
|
+
|
|
28
|
+
The HarmonySite fork requires ``bros_config`` and ``brosseccheck``
|
|
29
|
+
on every request URL, not just the session init. Standard KCFinder
|
|
30
|
+
installs return an empty dict.
|
|
31
|
+
"""
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SessionAuth(BaseAuth):
|
|
36
|
+
"""Auth for standard KCFinder installs with a pre-established session.
|
|
37
|
+
|
|
38
|
+
Use this when you already have a valid PHPSESSID cookie value.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, session_id: str, referer: str) -> None:
|
|
42
|
+
self._session_id = session_id
|
|
43
|
+
self._referer = referer
|
|
44
|
+
|
|
45
|
+
async def authenticate(self, session: httpx.AsyncClient) -> None:
|
|
46
|
+
session.cookies.set("PHPSESSID", self._session_id)
|
|
47
|
+
|
|
48
|
+
def authenticate_sync(self, session: httpx.Client) -> None:
|
|
49
|
+
session.cookies.set("PHPSESSID", self._session_id)
|
|
50
|
+
|
|
51
|
+
def get_referer(self) -> str:
|
|
52
|
+
return self._referer
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class HarmonySiteAuth(BaseAuth):
|
|
56
|
+
"""Auth for HarmonySite's KCFinder fork.
|
|
57
|
+
|
|
58
|
+
Authenticates in two steps:
|
|
59
|
+
1. POST to login_url with credentials to establish a web session
|
|
60
|
+
2. GET browse_url with bros_config and brosseccheck to init the KCFinder session
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
login_url: str,
|
|
66
|
+
browse_url: str,
|
|
67
|
+
username: str,
|
|
68
|
+
password: str,
|
|
69
|
+
bros_config: str,
|
|
70
|
+
brosseccheck: str = "Xx-ok-xX",
|
|
71
|
+
) -> None:
|
|
72
|
+
self._login_url = login_url
|
|
73
|
+
self._browse_url = browse_url
|
|
74
|
+
self._username = username
|
|
75
|
+
self._password = password
|
|
76
|
+
self._bros_config = bros_config
|
|
77
|
+
self._brosseccheck = brosseccheck
|
|
78
|
+
|
|
79
|
+
def _login_data(self) -> dict[str, str]:
|
|
80
|
+
"""Build the HarmonySite login form payload."""
|
|
81
|
+
return {
|
|
82
|
+
"dbase": "users",
|
|
83
|
+
"action": "login",
|
|
84
|
+
"username": self._username,
|
|
85
|
+
"password": self._password,
|
|
86
|
+
"nextpage": self._login_url.rsplit("/", 1)[0],
|
|
87
|
+
"login": "Log In",
|
|
88
|
+
"remember": "1",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async def authenticate(self, session: httpx.AsyncClient) -> None:
|
|
92
|
+
login_resp = await session.post(
|
|
93
|
+
self._login_url,
|
|
94
|
+
data=self._login_data(),
|
|
95
|
+
follow_redirects=True,
|
|
96
|
+
)
|
|
97
|
+
if login_resp.status_code != 200:
|
|
98
|
+
raise AuthError(f"Login failed with status {login_resp.status_code}")
|
|
99
|
+
|
|
100
|
+
init_resp = await session.get(self._init_url())
|
|
101
|
+
if init_resp.status_code != 200:
|
|
102
|
+
raise AuthError(
|
|
103
|
+
f"KCFinder session init failed with status {init_resp.status_code}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def authenticate_sync(self, session: httpx.Client) -> None:
|
|
107
|
+
login_resp = session.post(
|
|
108
|
+
self._login_url,
|
|
109
|
+
data=self._login_data(),
|
|
110
|
+
follow_redirects=True,
|
|
111
|
+
)
|
|
112
|
+
if login_resp.status_code != 200:
|
|
113
|
+
raise AuthError(f"Login failed with status {login_resp.status_code}")
|
|
114
|
+
|
|
115
|
+
init_resp = session.get(self._init_url())
|
|
116
|
+
if init_resp.status_code != 200:
|
|
117
|
+
raise AuthError(
|
|
118
|
+
f"KCFinder session init failed with status {init_resp.status_code}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def get_referer(self) -> str:
|
|
122
|
+
return self._init_url()
|
|
123
|
+
|
|
124
|
+
def get_query_params(self) -> dict[str, str]:
|
|
125
|
+
return {
|
|
126
|
+
"bros_config": self._bros_config,
|
|
127
|
+
"brosseccheck": self._brosseccheck,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
def _init_url(self) -> str:
|
|
131
|
+
params = urlencode(
|
|
132
|
+
{
|
|
133
|
+
"bros_config": self._bros_config,
|
|
134
|
+
"brosseccheck": self._brosseccheck,
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
return f"{self._browse_url}?{params}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def harmonysite_auth_from_env() -> HarmonySiteAuth:
|
|
141
|
+
"""Build a HarmonySiteAuth from environment variables.
|
|
142
|
+
|
|
143
|
+
Required env vars:
|
|
144
|
+
KCFINDER_LOGIN_URL: The HarmonySite login URL
|
|
145
|
+
KCFINDER_BROWSE_URL: The KCFinder browse.php URL
|
|
146
|
+
KCFINDER_USERNAME: Login username
|
|
147
|
+
KCFINDER_PASSWORD: Login password
|
|
148
|
+
KCFINDER_BROS_CONFIG: PHP-serialized bros_config string
|
|
149
|
+
|
|
150
|
+
Optional env vars:
|
|
151
|
+
KCFINDER_BROSSECCHECK: Security check token (default: "Xx-ok-xX")
|
|
152
|
+
"""
|
|
153
|
+
return HarmonySiteAuth(
|
|
154
|
+
login_url=os.environ["KCFINDER_LOGIN_URL"],
|
|
155
|
+
browse_url=os.environ["KCFINDER_BROWSE_URL"],
|
|
156
|
+
username=os.environ["KCFINDER_USERNAME"],
|
|
157
|
+
password=os.environ["KCFINDER_PASSWORD"],
|
|
158
|
+
bros_config=os.environ["KCFINDER_BROS_CONFIG"],
|
|
159
|
+
brosseccheck=os.environ.get("KCFINDER_BROSSECCHECK", "Xx-ok-xX"),
|
|
160
|
+
)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Sync client for KCFinder file manager operations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from kcfinder_client._core import (
|
|
10
|
+
build_action_url,
|
|
11
|
+
build_form_data,
|
|
12
|
+
build_headers,
|
|
13
|
+
check_action_error,
|
|
14
|
+
check_upload_response,
|
|
15
|
+
parse_dir_tree,
|
|
16
|
+
parse_expand_response,
|
|
17
|
+
parse_file_list,
|
|
18
|
+
prefix_dir,
|
|
19
|
+
prefix_file_paths,
|
|
20
|
+
)
|
|
21
|
+
from kcfinder_client.auth import BaseAuth
|
|
22
|
+
from kcfinder_client.models import DirTree, FileInfo
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class KCFinderClient:
|
|
26
|
+
"""Sync client for KCFinder file manager operations."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
browse_url: str,
|
|
31
|
+
auth: BaseAuth,
|
|
32
|
+
file_type: str = "images",
|
|
33
|
+
) -> None:
|
|
34
|
+
self._browse_url = browse_url
|
|
35
|
+
self._auth = auth
|
|
36
|
+
self._file_type = file_type
|
|
37
|
+
self._client: httpx.Client | None = None
|
|
38
|
+
|
|
39
|
+
def __enter__(self) -> KCFinderClient:
|
|
40
|
+
self._client = httpx.Client()
|
|
41
|
+
self._auth.authenticate_sync(self._client)
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def __exit__(
|
|
45
|
+
self,
|
|
46
|
+
exc_type: type[BaseException] | None,
|
|
47
|
+
exc_val: BaseException | None,
|
|
48
|
+
exc_tb: TracebackType | None,
|
|
49
|
+
) -> None:
|
|
50
|
+
if self._client:
|
|
51
|
+
self._client.close()
|
|
52
|
+
|
|
53
|
+
def _get_client(self) -> httpx.Client:
|
|
54
|
+
"""Return the active HTTP client, raising if not initialized."""
|
|
55
|
+
if self._client is None:
|
|
56
|
+
raise RuntimeError("Client not initialized. Use 'with' context manager.")
|
|
57
|
+
return self._client
|
|
58
|
+
|
|
59
|
+
def _post(self, action: str, data: dict) -> httpx.Response:
|
|
60
|
+
"""Send a POST request to KCFinder."""
|
|
61
|
+
url = build_action_url(
|
|
62
|
+
self._browse_url, action, self._file_type, self._auth.get_query_params()
|
|
63
|
+
)
|
|
64
|
+
headers = build_headers(self._auth.get_referer())
|
|
65
|
+
return self._get_client().post(url, data=data, headers=headers)
|
|
66
|
+
|
|
67
|
+
def list_files(self, dir: str = "") -> list[FileInfo]:
|
|
68
|
+
"""List files in a directory (root by default)."""
|
|
69
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir))
|
|
70
|
+
response = self._post("chDir", data)
|
|
71
|
+
return parse_file_list(response.json())
|
|
72
|
+
|
|
73
|
+
def upload(self, dir: str, files: Path | list[Path]) -> None:
|
|
74
|
+
"""Upload one or more files to a directory."""
|
|
75
|
+
if isinstance(files, Path):
|
|
76
|
+
files = [files]
|
|
77
|
+
url = build_action_url(
|
|
78
|
+
self._browse_url, "upload", self._file_type, self._auth.get_query_params()
|
|
79
|
+
)
|
|
80
|
+
url += "&" + urlencode({"dir": prefix_dir(self._file_type, dir)})
|
|
81
|
+
headers = build_headers(self._auth.get_referer())
|
|
82
|
+
upload_files = [("upload[]", (f.name, f.read_bytes())) for f in files]
|
|
83
|
+
response = self._get_client().post(
|
|
84
|
+
url,
|
|
85
|
+
files=upload_files,
|
|
86
|
+
headers=headers,
|
|
87
|
+
)
|
|
88
|
+
check_upload_response(response.text)
|
|
89
|
+
|
|
90
|
+
def delete(self, dir: str, file: str) -> None:
|
|
91
|
+
"""Delete a file."""
|
|
92
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir), file=file)
|
|
93
|
+
response = self._post("delete", data)
|
|
94
|
+
check_action_error("delete", response.text)
|
|
95
|
+
|
|
96
|
+
def rename(self, dir: str, file: str, new_name: str) -> None:
|
|
97
|
+
"""Rename a file."""
|
|
98
|
+
data = build_form_data(
|
|
99
|
+
dir=prefix_dir(self._file_type, dir),
|
|
100
|
+
file=file,
|
|
101
|
+
new_name=new_name,
|
|
102
|
+
)
|
|
103
|
+
response = self._post("rename", data)
|
|
104
|
+
check_action_error("rename", response.text)
|
|
105
|
+
|
|
106
|
+
def download(self, dir: str, file: str) -> bytes:
|
|
107
|
+
"""Download a file and return its content as bytes."""
|
|
108
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir), file=file)
|
|
109
|
+
response = self._post("download", data)
|
|
110
|
+
return response.content
|
|
111
|
+
|
|
112
|
+
def get_thumbnail(self, dir: str, file: str) -> bytes:
|
|
113
|
+
"""Get the thumbnail for a file as PNG bytes.
|
|
114
|
+
|
|
115
|
+
Thumbnails are served via GET parameters (used as ``<img src>``
|
|
116
|
+
in the browser UI), not POST form data.
|
|
117
|
+
"""
|
|
118
|
+
params = {
|
|
119
|
+
**self._auth.get_query_params(),
|
|
120
|
+
"act": "thumb",
|
|
121
|
+
"type": self._file_type,
|
|
122
|
+
"dir": prefix_dir(self._file_type, dir),
|
|
123
|
+
"file": file,
|
|
124
|
+
}
|
|
125
|
+
url = f"{self._browse_url}?{urlencode(params)}"
|
|
126
|
+
headers = build_headers(self._auth.get_referer())
|
|
127
|
+
response = self._get_client().get(url, headers=headers)
|
|
128
|
+
return response.content
|
|
129
|
+
|
|
130
|
+
def get_tree(self) -> DirTree:
|
|
131
|
+
"""Initialize the browser view and return the root directory node."""
|
|
132
|
+
url = build_action_url(
|
|
133
|
+
self._browse_url, "init", self._file_type, self._auth.get_query_params()
|
|
134
|
+
)
|
|
135
|
+
headers = build_headers(self._auth.get_referer())
|
|
136
|
+
response = self._get_client().post(url, headers=headers)
|
|
137
|
+
return parse_dir_tree(response.json())
|
|
138
|
+
|
|
139
|
+
def expand(self, dir: str = "") -> list[DirTree]:
|
|
140
|
+
"""Get subdirectory info for a directory (root by default)."""
|
|
141
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir))
|
|
142
|
+
response = self._post("expand", data)
|
|
143
|
+
return parse_expand_response(response.json())
|
|
144
|
+
|
|
145
|
+
def create_dir(self, dir: str, new_dir: str) -> None:
|
|
146
|
+
"""Create a new subdirectory."""
|
|
147
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir), new_dir=new_dir)
|
|
148
|
+
response = self._post("newDir", data)
|
|
149
|
+
check_action_error("newDir", response.text)
|
|
150
|
+
|
|
151
|
+
def rename_dir(self, dir: str, new_name: str) -> None:
|
|
152
|
+
"""Rename a directory."""
|
|
153
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir), new_name=new_name)
|
|
154
|
+
response = self._post("renameDir", data)
|
|
155
|
+
check_action_error("renameDir", response.text)
|
|
156
|
+
|
|
157
|
+
def delete_dir(self, dir: str) -> None:
|
|
158
|
+
"""Delete a directory and all its contents recursively.
|
|
159
|
+
|
|
160
|
+
Removes all files and subdirectories within the target directory
|
|
161
|
+
before removing the directory itself.
|
|
162
|
+
"""
|
|
163
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir))
|
|
164
|
+
response = self._post("deleteDir", data)
|
|
165
|
+
check_action_error("deleteDir", response.text)
|
|
166
|
+
|
|
167
|
+
def download_dir(self, dir: str) -> bytes:
|
|
168
|
+
"""Download a directory as a ZIP archive."""
|
|
169
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir))
|
|
170
|
+
response = self._post("downloadDir", data)
|
|
171
|
+
return response.content
|
|
172
|
+
|
|
173
|
+
def _bulk_action(
|
|
174
|
+
self, action: str, files: list[str], dest: str | None = None
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Shared logic for bulk copy/move/delete actions.
|
|
177
|
+
|
|
178
|
+
File paths are automatically prefixed with the file type (e.g.,
|
|
179
|
+
``images/subdir/file.jpg``) as required by the KCFinder protocol.
|
|
180
|
+
"""
|
|
181
|
+
prefixed = prefix_file_paths(self._file_type, files)
|
|
182
|
+
data = build_form_data(files=prefixed)
|
|
183
|
+
if dest is not None:
|
|
184
|
+
data["dir"] = prefix_dir(self._file_type, dest)
|
|
185
|
+
response = self._post(action, data)
|
|
186
|
+
check_action_error(action, response.text)
|
|
187
|
+
|
|
188
|
+
def copy(self, files: str | list[str], dest: str) -> None:
|
|
189
|
+
"""Copy files to a destination directory."""
|
|
190
|
+
if isinstance(files, str):
|
|
191
|
+
files = [files]
|
|
192
|
+
self._bulk_action("cp_cbd", files, dest)
|
|
193
|
+
|
|
194
|
+
def move(self, files: str | list[str], dest: str) -> None:
|
|
195
|
+
"""Move files to a destination directory."""
|
|
196
|
+
if isinstance(files, str):
|
|
197
|
+
files = [files]
|
|
198
|
+
self._bulk_action("mv_cbd", files, dest)
|
|
199
|
+
|
|
200
|
+
def bulk_delete(self, files: list[str]) -> None:
|
|
201
|
+
"""Delete multiple files."""
|
|
202
|
+
self._bulk_action("rm_cbd", files)
|
|
203
|
+
|
|
204
|
+
def download_selected(self, dir: str, files: list[str]) -> bytes:
|
|
205
|
+
"""Download selected files as a ZIP archive.
|
|
206
|
+
|
|
207
|
+
Unlike bulk clipboard operations, ``files`` here are plain
|
|
208
|
+
filenames (basenames) within the specified directory.
|
|
209
|
+
"""
|
|
210
|
+
data = build_form_data(dir=prefix_dir(self._file_type, dir), files=files)
|
|
211
|
+
response = self._post("downloadSelected", data)
|
|
212
|
+
return response.content
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Exception hierarchy for the KCFinder client."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class KCFinderError(Exception):
|
|
7
|
+
"""Base exception for all KCFinder errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthError(KCFinderError):
|
|
11
|
+
"""Authentication or session initialization failed."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ActionError(KCFinderError):
|
|
15
|
+
"""A KCFinder action returned an error in the response body."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, action: str, message: str) -> None:
|
|
18
|
+
self.action = action
|
|
19
|
+
self.message = message
|
|
20
|
+
super().__init__(f"{action}: {message}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DirectoryOperationError(ActionError):
|
|
24
|
+
"""Directory creation, rename, or deletion failed."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FileOperationError(ActionError):
|
|
28
|
+
"""File not found, cannot copy/move/delete, name conflict, etc."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PermissionDeniedError(ActionError):
|
|
32
|
+
"""Operation denied due to insufficient permissions."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UploadError(ActionError):
|
|
36
|
+
"""One or more files failed to upload."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Error message classification
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Patterns derived from the KCFinder PHP source (sunhater/kcfinder).
|
|
43
|
+
# Order matters within each group; groups are checked permission → directory
|
|
44
|
+
# → file → upload, with ActionError as the fallback.
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
_PERMISSION_PATTERNS: list[re.Pattern[str]] = [
|
|
48
|
+
re.compile(r"don't have permissions", re.IGNORECASE),
|
|
49
|
+
re.compile(r"Cannot access or write to upload folder", re.IGNORECASE),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
_DIR_PATTERNS: list[re.Pattern[str]] = [
|
|
53
|
+
re.compile(r"Cannot (create .+ |rename the |delete the )folder", re.IGNORECASE),
|
|
54
|
+
re.compile(r"enter new folder name", re.IGNORECASE),
|
|
55
|
+
re.compile(r"characters in folder name", re.IGNORECASE),
|
|
56
|
+
re.compile(r"Folder name shouldn't begins with", re.IGNORECASE),
|
|
57
|
+
re.compile(r"Failed to delete .+ files/folders", re.IGNORECASE),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
_FILE_PATTERNS: list[re.Pattern[str]] = [
|
|
61
|
+
re.compile(r"does not exist", re.IGNORECASE),
|
|
62
|
+
re.compile(r"Cannot (read|copy|move|delete) '", re.IGNORECASE),
|
|
63
|
+
re.compile(r"cannot rename the extension", re.IGNORECASE),
|
|
64
|
+
re.compile(r"already exists", re.IGNORECASE),
|
|
65
|
+
re.compile(r"enter new file name", re.IGNORECASE),
|
|
66
|
+
re.compile(r"characters in file name", re.IGNORECASE),
|
|
67
|
+
re.compile(r"File name shouldn't begins with", re.IGNORECASE),
|
|
68
|
+
re.compile(r"Denied file extension", re.IGNORECASE),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
_UPLOAD_PATTERNS: list[re.Pattern[str]] = [
|
|
72
|
+
re.compile(r"uploaded file", re.IGNORECASE),
|
|
73
|
+
re.compile(r"No file was uploaded", re.IGNORECASE),
|
|
74
|
+
re.compile(r"Missing a temporary folder", re.IGNORECASE),
|
|
75
|
+
re.compile(r"Failed to write file", re.IGNORECASE),
|
|
76
|
+
re.compile(r"Cannot move uploaded file", re.IGNORECASE),
|
|
77
|
+
re.compile(r"too big and/or cannot be resized", re.IGNORECASE),
|
|
78
|
+
re.compile(r"Non-existing directory type", re.IGNORECASE),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def classify_error(action: str, message: str) -> ActionError:
|
|
83
|
+
"""Return the most specific ActionError subclass for a KCFinder error message."""
|
|
84
|
+
for pattern in _PERMISSION_PATTERNS:
|
|
85
|
+
if pattern.search(message):
|
|
86
|
+
return PermissionDeniedError(action=action, message=message)
|
|
87
|
+
for pattern in _DIR_PATTERNS:
|
|
88
|
+
if pattern.search(message):
|
|
89
|
+
return DirectoryOperationError(action=action, message=message)
|
|
90
|
+
for pattern in _FILE_PATTERNS:
|
|
91
|
+
if pattern.search(message):
|
|
92
|
+
return FileOperationError(action=action, message=message)
|
|
93
|
+
for pattern in _UPLOAD_PATTERNS:
|
|
94
|
+
if pattern.search(message):
|
|
95
|
+
return UploadError(action=action, message=message)
|
|
96
|
+
return ActionError(action=action, message=message)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class FileInfo:
|
|
7
|
+
"""A file entry returned by KCFinder."""
|
|
8
|
+
|
|
9
|
+
name: str
|
|
10
|
+
size: int
|
|
11
|
+
mtime: datetime
|
|
12
|
+
is_writable: bool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DirTree:
|
|
17
|
+
"""A directory node from KCFinder's init or expand actions.
|
|
18
|
+
|
|
19
|
+
The server returns only one level of subdirectories at a time.
|
|
20
|
+
Use ``expand()`` to load deeper levels on demand.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
is_writable: bool
|
|
25
|
+
has_subdirs: bool
|
|
26
|
+
children: list[DirTree]
|
|
27
|
+
files: list[FileInfo]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SyncResult:
|
|
32
|
+
"""Result of a push sync operation."""
|
|
33
|
+
|
|
34
|
+
uploaded: list[str]
|
|
35
|
+
deleted: list[str]
|
|
36
|
+
skipped: list[str]
|
kcfinder_client/sync.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""One-way push sync: make a remote directory match a local directory."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from kcfinder_client.async_client import AsyncKCFinderClient
|
|
6
|
+
from kcfinder_client.client import KCFinderClient
|
|
7
|
+
from kcfinder_client.models import FileInfo, SyncResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _compute_sync_actions(
|
|
11
|
+
local_files: dict[str, int],
|
|
12
|
+
remote_files: dict[str, int],
|
|
13
|
+
) -> SyncResult:
|
|
14
|
+
"""Compare local and remote file sets by name and size."""
|
|
15
|
+
uploaded = []
|
|
16
|
+
skipped = []
|
|
17
|
+
deleted = []
|
|
18
|
+
|
|
19
|
+
for name, local_size in sorted(local_files.items()):
|
|
20
|
+
if name in remote_files and remote_files[name] == local_size:
|
|
21
|
+
skipped.append(name)
|
|
22
|
+
else:
|
|
23
|
+
uploaded.append(name)
|
|
24
|
+
|
|
25
|
+
for name in sorted(remote_files):
|
|
26
|
+
if name not in local_files:
|
|
27
|
+
deleted.append(name)
|
|
28
|
+
|
|
29
|
+
return SyncResult(uploaded=uploaded, deleted=deleted, skipped=skipped)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _scan_local_dir(local_dir: Path) -> dict[str, int]:
|
|
33
|
+
"""Scan a local directory and return a dict of filename -> size."""
|
|
34
|
+
return {f.name: f.stat().st_size for f in local_dir.iterdir() if f.is_file()}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _remote_file_map(files: list[FileInfo]) -> dict[str, int]:
|
|
38
|
+
"""Convert a list of FileInfo to a dict of filename -> size."""
|
|
39
|
+
return {f.name: f.size for f in files}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SyncManager:
|
|
43
|
+
"""Async one-way push sync manager."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, client: AsyncKCFinderClient) -> None:
|
|
46
|
+
self._client = client
|
|
47
|
+
|
|
48
|
+
async def push(
|
|
49
|
+
self, remote_dir: str, local_dir: Path, *, dry_run: bool = False
|
|
50
|
+
) -> SyncResult:
|
|
51
|
+
"""Sync local directory to remote, making remote match local."""
|
|
52
|
+
remote_files = await self._client.list_files(remote_dir)
|
|
53
|
+
local_map = _scan_local_dir(local_dir)
|
|
54
|
+
remote_map = _remote_file_map(remote_files)
|
|
55
|
+
result = _compute_sync_actions(local_map, remote_map)
|
|
56
|
+
|
|
57
|
+
if dry_run:
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
for name in result.uploaded:
|
|
61
|
+
await self._client.upload(remote_dir, local_dir / name)
|
|
62
|
+
|
|
63
|
+
for name in result.deleted:
|
|
64
|
+
await self._client.delete(remote_dir, name)
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SyncManagerSync:
|
|
70
|
+
"""Sync (non-async) one-way push sync manager."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, client: KCFinderClient) -> None:
|
|
73
|
+
self._client = client
|
|
74
|
+
|
|
75
|
+
def push(
|
|
76
|
+
self, remote_dir: str, local_dir: Path, *, dry_run: bool = False
|
|
77
|
+
) -> SyncResult:
|
|
78
|
+
"""Sync local directory to remote, making remote match local."""
|
|
79
|
+
remote_files = self._client.list_files(remote_dir)
|
|
80
|
+
local_map = _scan_local_dir(local_dir)
|
|
81
|
+
remote_map = _remote_file_map(remote_files)
|
|
82
|
+
result = _compute_sync_actions(local_map, remote_map)
|
|
83
|
+
|
|
84
|
+
if dry_run:
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
for name in result.uploaded:
|
|
88
|
+
self._client.upload(remote_dir, local_dir / name)
|
|
89
|
+
|
|
90
|
+
for name in result.deleted:
|
|
91
|
+
self._client.delete(remote_dir, name)
|
|
92
|
+
|
|
93
|
+
return result
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kcfinder-client
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client library for KCFinder file manager
|
|
5
|
+
Project-URL: Changelog, https://github.com/cyberops7/kcfinder-client/releases
|
|
6
|
+
Project-URL: Issues, https://github.com/cyberops7/kcfinder-client/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/cyberops7/kcfinder-client
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: file-manager,httpx,kcfinder,php
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Requires-Dist: httpx>=0.28
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# kcfinder-client
|
|
23
|
+
|
|
24
|
+
[](https://github.com/cyberops7/kcfinder-client/actions/workflows/ci.yml)
|
|
25
|
+

|
|
26
|
+
[](https://opensource.org/licenses/MIT)
|
|
27
|
+
|
|
28
|
+
A Python client library for [KCFinder](https://github.com/sunhater/kcfinder),
|
|
29
|
+
a PHP web file manager. Wraps KCFinder's HTTP action-dispatch API into a clean
|
|
30
|
+
Python interface with both async and sync clients.
|
|
31
|
+
|
|
32
|
+
> [!NOTE]
|
|
33
|
+
> Designed for and tested against HarmonySite's KCFinder fork.
|
|
34
|
+
> May work with other KCFinder instances via `SessionAuth`, but this
|
|
35
|
+
> has not been tested.
|
|
36
|
+
|
|
37
|
+
## Table of Contents
|
|
38
|
+
|
|
39
|
+
- [Install](#install)
|
|
40
|
+
- [Quick Start](#quick-start)
|
|
41
|
+
- [Auth from Environment Variables](#auth-from-environment-variables)
|
|
42
|
+
- [Sync Files with SyncManager](#sync-files-with-syncmanager)
|
|
43
|
+
- [Documentation](#documentation)
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# uv
|
|
49
|
+
uv add kcfinder-client
|
|
50
|
+
|
|
51
|
+
# pip
|
|
52
|
+
pip install kcfinder-client
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quick Start
|
|
56
|
+
|
|
57
|
+
### Async (recommended)
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import asyncio
|
|
61
|
+
import os
|
|
62
|
+
from kcfinder_client import AsyncKCFinderClient, HarmonySiteAuth
|
|
63
|
+
|
|
64
|
+
auth = HarmonySiteAuth(
|
|
65
|
+
login_url="https://example.harmonysong.com/dbaction.php",
|
|
66
|
+
browse_url="https://example.harmonysong.com/kcfinder/browse.php",
|
|
67
|
+
username="admin",
|
|
68
|
+
password="secret",
|
|
69
|
+
bros_config=(
|
|
70
|
+
'a:3:{s:9:"uploadDir";s:6:"files/";'
|
|
71
|
+
's:9:"thumbsDir";s:14:"files/.thumbs/";'
|
|
72
|
+
's:9:"uploadURL";s:0:"";}'
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def main():
|
|
77
|
+
browse_url = "https://example.harmonysong.com/kcfinder/browse.php"
|
|
78
|
+
async with AsyncKCFinderClient(browse_url, auth) as client:
|
|
79
|
+
files = await client.list_files("banners")
|
|
80
|
+
for f in files:
|
|
81
|
+
print(f.name, f.size)
|
|
82
|
+
|
|
83
|
+
asyncio.run(main())
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Sync
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from kcfinder_client import KCFinderClient, HarmonySiteAuth
|
|
90
|
+
|
|
91
|
+
auth = HarmonySiteAuth(
|
|
92
|
+
login_url="https://example.harmonysong.com/dbaction.php",
|
|
93
|
+
browse_url="https://example.harmonysong.com/kcfinder/browse.php",
|
|
94
|
+
username="admin",
|
|
95
|
+
password="secret",
|
|
96
|
+
bros_config=(
|
|
97
|
+
'a:3:{s:9:"uploadDir";s:6:"files/";'
|
|
98
|
+
's:9:"thumbsDir";s:14:"files/.thumbs/";'
|
|
99
|
+
's:9:"uploadURL";s:0:"";}'
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
browse_url = "https://example.harmonysong.com/kcfinder/browse.php"
|
|
104
|
+
with KCFinderClient(browse_url, auth) as client:
|
|
105
|
+
files = client.list_files("banners")
|
|
106
|
+
for f in files:
|
|
107
|
+
print(f.name, f.size)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Auth from Environment Variables
|
|
111
|
+
|
|
112
|
+
Load credentials from the environment rather than hardcoding them:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
export KCFINDER_LOGIN_URL=https://example.harmonysong.com/dbaction.php
|
|
116
|
+
export KCFINDER_BROWSE_URL=https://example.harmonysong.com/kcfinder/browse.php
|
|
117
|
+
export KCFINDER_USERNAME=admin
|
|
118
|
+
export KCFINDER_PASSWORD=secret
|
|
119
|
+
export KCFINDER_BROS_CONFIG='a:3:{s:9:"uploadDir";s:6:"files/";s:9:"thumbsDir";s:14:"files/.thumbs/";s:9:"uploadURL";s:0:"";}'
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
import os
|
|
124
|
+
from kcfinder_client import AsyncKCFinderClient, harmonysite_auth_from_env
|
|
125
|
+
import asyncio
|
|
126
|
+
|
|
127
|
+
async def main():
|
|
128
|
+
auth = harmonysite_auth_from_env()
|
|
129
|
+
browse_url = os.environ["KCFINDER_BROWSE_URL"]
|
|
130
|
+
async with AsyncKCFinderClient(browse_url, auth) as client:
|
|
131
|
+
files = await client.list_files()
|
|
132
|
+
print(f"Found {len(files)} files")
|
|
133
|
+
|
|
134
|
+
asyncio.run(main())
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Sync Files with SyncManager
|
|
138
|
+
|
|
139
|
+
One-way push sync to make a remote directory match a local one:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import os
|
|
143
|
+
from pathlib import Path
|
|
144
|
+
from kcfinder_client import KCFinderClient, SyncManagerSync, harmonysite_auth_from_env
|
|
145
|
+
|
|
146
|
+
auth = harmonysite_auth_from_env()
|
|
147
|
+
browse_url = os.environ["KCFINDER_BROWSE_URL"]
|
|
148
|
+
|
|
149
|
+
with KCFinderClient(browse_url, auth) as client:
|
|
150
|
+
sync = SyncManagerSync(client)
|
|
151
|
+
|
|
152
|
+
# Dry run first to preview changes
|
|
153
|
+
result = sync.push("banners", Path("./banners"), dry_run=True)
|
|
154
|
+
print(f"Would upload: {result.uploaded}")
|
|
155
|
+
print(f"Would delete: {result.deleted}")
|
|
156
|
+
print(f"Would skip: {result.skipped}")
|
|
157
|
+
|
|
158
|
+
# Apply the sync
|
|
159
|
+
result = sync.push("banners", Path("./banners"))
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Documentation
|
|
163
|
+
|
|
164
|
+
- [Auth setup](docs/auth.md) — HarmonySiteAuth, SessionAuth, env vars
|
|
165
|
+
- [File operations](docs/files.md) — list, upload, download, delete, rename,
|
|
166
|
+
thumbnails
|
|
167
|
+
- [Directory operations](docs/directories.md) — tree, expand, create, rename,
|
|
168
|
+
delete
|
|
169
|
+
- [Bulk operations](docs/bulk.md) — copy, move, bulk delete, download selected
|
|
170
|
+
- [Sync manager](docs/sync.md) — push sync, dry run, SyncResult
|
|
171
|
+
- [Exceptions](docs/exceptions.md) — error handling, exception hierarchy
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
kcfinder_client/__init__.py,sha256=WJ10BvBqmT5z6P1LpHDHTRp_-0L8BDWJVBvQ9mp__nY,1013
|
|
2
|
+
kcfinder_client/_core.py,sha256=J5DOjXHCqD8MGTbEJE_hoaQgUzfaFvkDVEpPo8GeBq4,6137
|
|
3
|
+
kcfinder_client/async_client.py,sha256=PQ64rKyqGkfv9uzE60aWTOhc_knz0KM9hKji-i7fnoE,8321
|
|
4
|
+
kcfinder_client/auth.py,sha256=-HMW3cn9_zqP6kZnjibKOtcqPX0U723cdcQxtEt3Tg8,5189
|
|
5
|
+
kcfinder_client/client.py,sha256=QlqMhtOeoetn1DKPGg1cn8ZtmX2x-30jibwYvcyIdRo,8020
|
|
6
|
+
kcfinder_client/exceptions.py,sha256=aMEGtAFOr46akKc-Qh2Ey9CFyYIDfBc25lDLWiCptZk,3706
|
|
7
|
+
kcfinder_client/models.py,sha256=HEOOQPNQBLH6e4UOd-SOXdmdZfU6iZegKRlLH4NiSLw,689
|
|
8
|
+
kcfinder_client/sync.py,sha256=eJUnK2CWHSlNi1gkATmqrvPq0KZuXJuQV2B0SrEFzJw,2941
|
|
9
|
+
kcfinder_client-0.2.0.dist-info/METADATA,sha256=5e0nwQOtt0H_ZWlGdbuhomVrU-h3Fvw_4LdYMgSNJPs,5403
|
|
10
|
+
kcfinder_client-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
kcfinder_client-0.2.0.dist-info/licenses/LICENSE,sha256=ynoj1mLUphGX7jiRCgzPXIyhNQRpz5J1oLVumMoFsus,1066
|
|
12
|
+
kcfinder_client-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cyberops7
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|