SuperRealCo commited on
Commit
519d7a9
·
verified ·
1 Parent(s): 592ea8b

Delete app

Browse files
app/__init__.py DELETED
File without changes
app/app_settings.py DELETED
@@ -1,65 +0,0 @@
1
- import os
2
- import json
3
- from aiohttp import web
4
- import logging
5
-
6
-
7
- class AppSettings():
8
- def __init__(self, user_manager):
9
- self.user_manager = user_manager
10
-
11
- def get_settings(self, request):
12
- try:
13
- file = self.user_manager.get_request_user_filepath(
14
- request,
15
- "comfy.settings.json"
16
- )
17
- except KeyError as e:
18
- logging.error("User settings not found.")
19
- raise web.HTTPUnauthorized() from e
20
- if os.path.isfile(file):
21
- try:
22
- with open(file) as f:
23
- return json.load(f)
24
- except:
25
- logging.error(f"The user settings file is corrupted: {file}")
26
- return {}
27
- else:
28
- return {}
29
-
30
- def save_settings(self, request, settings):
31
- file = self.user_manager.get_request_user_filepath(
32
- request, "comfy.settings.json")
33
- with open(file, "w") as f:
34
- f.write(json.dumps(settings, indent=4))
35
-
36
- def add_routes(self, routes):
37
- @routes.get("/settings")
38
- async def get_settings(request):
39
- return web.json_response(self.get_settings(request))
40
-
41
- @routes.get("/settings/{id}")
42
- async def get_setting(request):
43
- value = None
44
- settings = self.get_settings(request)
45
- setting_id = request.match_info.get("id", None)
46
- if setting_id and setting_id in settings:
47
- value = settings[setting_id]
48
- return web.json_response(value)
49
-
50
- @routes.post("/settings")
51
- async def post_settings(request):
52
- settings = self.get_settings(request)
53
- new_settings = await request.json()
54
- self.save_settings(request, {**settings, **new_settings})
55
- return web.Response(status=200)
56
-
57
- @routes.post("/settings/{id}")
58
- async def post_setting(request):
59
- setting_id = request.match_info.get("id", None)
60
- if not setting_id:
61
- return web.Response(status=400)
62
- settings = self.get_settings(request)
63
- settings[setting_id] = await request.json()
64
- self.save_settings(request, settings)
65
- return web.Response(status=200)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/custom_node_manager.py DELETED
@@ -1,145 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import folder_paths
5
- import glob
6
- from aiohttp import web
7
- import json
8
- import logging
9
- from functools import lru_cache
10
-
11
- from utils.json_util import merge_json_recursive
12
-
13
-
14
- # Extra locale files to load into main.json
15
- EXTRA_LOCALE_FILES = [
16
- "nodeDefs.json",
17
- "commands.json",
18
- "settings.json",
19
- ]
20
-
21
-
22
- def safe_load_json_file(file_path: str) -> dict:
23
- if not os.path.exists(file_path):
24
- return {}
25
-
26
- try:
27
- with open(file_path, "r", encoding="utf-8") as f:
28
- return json.load(f)
29
- except json.JSONDecodeError:
30
- logging.error(f"Error loading {file_path}")
31
- return {}
32
-
33
-
34
- class CustomNodeManager:
35
- @lru_cache(maxsize=1)
36
- def build_translations(self):
37
- """Load all custom nodes translations during initialization. Translations are
38
- expected to be loaded from `locales/` folder.
39
-
40
- The folder structure is expected to be the following:
41
- - custom_nodes/
42
- - custom_node_1/
43
- - locales/
44
- - en/
45
- - main.json
46
- - commands.json
47
- - settings.json
48
-
49
- returned translations are expected to be in the following format:
50
- {
51
- "en": {
52
- "nodeDefs": {...},
53
- "commands": {...},
54
- "settings": {...},
55
- ...{other main.json keys}
56
- }
57
- }
58
- """
59
-
60
- translations = {}
61
-
62
- for folder in folder_paths.get_folder_paths("custom_nodes"):
63
- # Sort glob results for deterministic ordering
64
- for custom_node_dir in sorted(glob.glob(os.path.join(folder, "*/"))):
65
- locales_dir = os.path.join(custom_node_dir, "locales")
66
- if not os.path.exists(locales_dir):
67
- continue
68
-
69
- for lang_dir in glob.glob(os.path.join(locales_dir, "*/")):
70
- lang_code = os.path.basename(os.path.dirname(lang_dir))
71
-
72
- if lang_code not in translations:
73
- translations[lang_code] = {}
74
-
75
- # Load main.json
76
- main_file = os.path.join(lang_dir, "main.json")
77
- node_translations = safe_load_json_file(main_file)
78
-
79
- # Load extra locale files
80
- for extra_file in EXTRA_LOCALE_FILES:
81
- extra_file_path = os.path.join(lang_dir, extra_file)
82
- key = extra_file.split(".")[0]
83
- json_data = safe_load_json_file(extra_file_path)
84
- if json_data:
85
- node_translations[key] = json_data
86
-
87
- if node_translations:
88
- translations[lang_code] = merge_json_recursive(
89
- translations[lang_code], node_translations
90
- )
91
-
92
- return translations
93
-
94
- def add_routes(self, routes, webapp, loadedModules):
95
-
96
- example_workflow_folder_names = ["example_workflows", "example", "examples", "workflow", "workflows"]
97
-
98
- @routes.get("/workflow_templates")
99
- async def get_workflow_templates(request):
100
- """Returns a web response that contains the map of custom_nodes names and their associated workflow templates. The ones without templates are omitted."""
101
-
102
- files = []
103
-
104
- for folder in folder_paths.get_folder_paths("custom_nodes"):
105
- for folder_name in example_workflow_folder_names:
106
- pattern = os.path.join(folder, f"*/{folder_name}/*.json")
107
- matched_files = glob.glob(pattern)
108
- files.extend(matched_files)
109
-
110
- workflow_templates_dict = (
111
- {}
112
- ) # custom_nodes folder name -> example workflow names
113
- for file in files:
114
- custom_nodes_name = os.path.basename(
115
- os.path.dirname(os.path.dirname(file))
116
- )
117
- workflow_name = os.path.splitext(os.path.basename(file))[0]
118
- workflow_templates_dict.setdefault(custom_nodes_name, []).append(
119
- workflow_name
120
- )
121
- return web.json_response(workflow_templates_dict)
122
-
123
- # Serve workflow templates from custom nodes.
124
- for module_name, module_dir in loadedModules:
125
- for folder_name in example_workflow_folder_names:
126
- workflows_dir = os.path.join(module_dir, folder_name)
127
-
128
- if os.path.exists(workflows_dir):
129
- if folder_name != "example_workflows":
130
- logging.debug(
131
- "Found example workflow folder '%s' for custom node '%s', consider renaming it to 'example_workflows'",
132
- folder_name, module_name)
133
-
134
- webapp.add_routes(
135
- [
136
- web.static(
137
- "/api/workflow_templates/" + module_name, workflows_dir
138
- )
139
- ]
140
- )
141
-
142
- @routes.get("/i18n")
143
- async def get_i18n(request):
144
- """Returns translations from all custom nodes' locales folders."""
145
- return web.json_response(self.build_translations())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/database/db.py DELETED
@@ -1,112 +0,0 @@
1
- import logging
2
- import os
3
- import shutil
4
- from app.logger import log_startup_warning
5
- from utils.install_util import get_missing_requirements_message
6
- from comfy.cli_args import args
7
-
8
- _DB_AVAILABLE = False
9
- Session = None
10
-
11
-
12
- try:
13
- from alembic import command
14
- from alembic.config import Config
15
- from alembic.runtime.migration import MigrationContext
16
- from alembic.script import ScriptDirectory
17
- from sqlalchemy import create_engine
18
- from sqlalchemy.orm import sessionmaker
19
-
20
- _DB_AVAILABLE = True
21
- except ImportError as e:
22
- log_startup_warning(
23
- f"""
24
- ------------------------------------------------------------------------
25
- Error importing dependencies: {e}
26
- {get_missing_requirements_message()}
27
- This error is happening because ComfyUI now uses a local sqlite database.
28
- ------------------------------------------------------------------------
29
- """.strip()
30
- )
31
-
32
-
33
- def dependencies_available():
34
- """
35
- Temporary function to check if the dependencies are available
36
- """
37
- return _DB_AVAILABLE
38
-
39
-
40
- def can_create_session():
41
- """
42
- Temporary function to check if the database is available to create a session
43
- During initial release there may be environmental issues (or missing dependencies) that prevent the database from being created
44
- """
45
- return dependencies_available() and Session is not None
46
-
47
-
48
- def get_alembic_config():
49
- root_path = os.path.join(os.path.dirname(__file__), "../..")
50
- config_path = os.path.abspath(os.path.join(root_path, "alembic.ini"))
51
- scripts_path = os.path.abspath(os.path.join(root_path, "alembic_db"))
52
-
53
- config = Config(config_path)
54
- config.set_main_option("script_location", scripts_path)
55
- config.set_main_option("sqlalchemy.url", args.database_url)
56
-
57
- return config
58
-
59
-
60
- def get_db_path():
61
- url = args.database_url
62
- if url.startswith("sqlite:///"):
63
- return url.split("///")[1]
64
- else:
65
- raise ValueError(f"Unsupported database URL '{url}'.")
66
-
67
-
68
- def init_db():
69
- db_url = args.database_url
70
- logging.debug(f"Database URL: {db_url}")
71
- db_path = get_db_path()
72
- db_exists = os.path.exists(db_path)
73
-
74
- config = get_alembic_config()
75
-
76
- # Check if we need to upgrade
77
- engine = create_engine(db_url)
78
- conn = engine.connect()
79
-
80
- context = MigrationContext.configure(conn)
81
- current_rev = context.get_current_revision()
82
-
83
- script = ScriptDirectory.from_config(config)
84
- target_rev = script.get_current_head()
85
-
86
- if target_rev is None:
87
- logging.warning("No target revision found.")
88
- elif current_rev != target_rev:
89
- # Backup the database pre upgrade
90
- backup_path = db_path + ".bkp"
91
- if db_exists:
92
- shutil.copy(db_path, backup_path)
93
- else:
94
- backup_path = None
95
-
96
- try:
97
- command.upgrade(config, target_rev)
98
- logging.info(f"Database upgraded from {current_rev} to {target_rev}")
99
- except Exception as e:
100
- if backup_path:
101
- # Restore the database from backup if upgrade fails
102
- shutil.copy(backup_path, db_path)
103
- os.remove(backup_path)
104
- logging.exception("Error upgrading database: ")
105
- raise e
106
-
107
- global Session
108
- Session = sessionmaker(bind=engine)
109
-
110
-
111
- def create_session():
112
- return Session()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/database/models.py DELETED
@@ -1,14 +0,0 @@
1
- from sqlalchemy.orm import declarative_base
2
-
3
- Base = declarative_base()
4
-
5
-
6
- def to_dict(obj):
7
- fields = obj.__table__.columns.keys()
8
- return {
9
- field: (val.to_dict() if hasattr(val, "to_dict") else val)
10
- for field in fields
11
- if (val := getattr(obj, field))
12
- }
13
-
14
- # TODO: Define models here
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/frontend_management.py DELETED
@@ -1,326 +0,0 @@
1
- from __future__ import annotations
2
- import argparse
3
- import logging
4
- import os
5
- import re
6
- import sys
7
- import tempfile
8
- import zipfile
9
- import importlib
10
- from dataclasses import dataclass
11
- from functools import cached_property
12
- from pathlib import Path
13
- from typing import TypedDict, Optional
14
- from importlib.metadata import version
15
-
16
- import requests
17
- from typing_extensions import NotRequired
18
-
19
- from utils.install_util import get_missing_requirements_message, requirements_path
20
-
21
- from comfy.cli_args import DEFAULT_VERSION_STRING
22
- import app.logger
23
-
24
-
25
- def frontend_install_warning_message():
26
- return f"""
27
- {get_missing_requirements_message()}
28
-
29
- This error is happening because the ComfyUI frontend is no longer shipped as part of the main repo but as a pip package instead.
30
- """.strip()
31
-
32
-
33
- def check_frontend_version():
34
- """Check if the frontend version is up to date."""
35
-
36
- def parse_version(version: str) -> tuple[int, int, int]:
37
- return tuple(map(int, version.split(".")))
38
-
39
- try:
40
- frontend_version_str = version("comfyui-frontend-package")
41
- frontend_version = parse_version(frontend_version_str)
42
- with open(requirements_path, "r", encoding="utf-8") as f:
43
- required_frontend = parse_version(f.readline().split("=")[-1])
44
- if frontend_version < required_frontend:
45
- app.logger.log_startup_warning(
46
- f"""
47
- ________________________________________________________________________
48
- WARNING WARNING WARNING WARNING WARNING
49
-
50
- Installed frontend version {".".join(map(str, frontend_version))} is lower than the recommended version {".".join(map(str, required_frontend))}.
51
-
52
- {frontend_install_warning_message()}
53
- ________________________________________________________________________
54
- """.strip()
55
- )
56
- else:
57
- logging.info("ComfyUI frontend version: {}".format(frontend_version_str))
58
- except Exception as e:
59
- logging.error(f"Failed to check frontend version: {e}")
60
-
61
-
62
- REQUEST_TIMEOUT = 10 # seconds
63
-
64
-
65
- class Asset(TypedDict):
66
- url: str
67
-
68
-
69
- class Release(TypedDict):
70
- id: int
71
- tag_name: str
72
- name: str
73
- prerelease: bool
74
- created_at: str
75
- published_at: str
76
- body: str
77
- assets: NotRequired[list[Asset]]
78
-
79
-
80
- @dataclass
81
- class FrontEndProvider:
82
- owner: str
83
- repo: str
84
-
85
- @property
86
- def folder_name(self) -> str:
87
- return f"{self.owner}_{self.repo}"
88
-
89
- @property
90
- def release_url(self) -> str:
91
- return f"https://api.github.com/repos/{self.owner}/{self.repo}/releases"
92
-
93
- @cached_property
94
- def all_releases(self) -> list[Release]:
95
- releases = []
96
- api_url = self.release_url
97
- while api_url:
98
- response = requests.get(api_url, timeout=REQUEST_TIMEOUT)
99
- response.raise_for_status() # Raises an HTTPError if the response was an error
100
- releases.extend(response.json())
101
- # GitHub uses the Link header to provide pagination links. Check if it exists and update api_url accordingly.
102
- if "next" in response.links:
103
- api_url = response.links["next"]["url"]
104
- else:
105
- api_url = None
106
- return releases
107
-
108
- @cached_property
109
- def latest_release(self) -> Release:
110
- latest_release_url = f"{self.release_url}/latest"
111
- response = requests.get(latest_release_url, timeout=REQUEST_TIMEOUT)
112
- response.raise_for_status() # Raises an HTTPError if the response was an error
113
- return response.json()
114
-
115
- @cached_property
116
- def latest_prerelease(self) -> Release:
117
- """Get the latest pre-release version - even if it's older than the latest release"""
118
- release = [release for release in self.all_releases if release["prerelease"]]
119
-
120
- if not release:
121
- raise ValueError("No pre-releases found")
122
-
123
- # GitHub returns releases in reverse chronological order, so first is latest
124
- return release[0]
125
-
126
- def get_release(self, version: str) -> Release:
127
- if version == "latest":
128
- return self.latest_release
129
- elif version == "prerelease":
130
- return self.latest_prerelease
131
- else:
132
- for release in self.all_releases:
133
- if release["tag_name"] in [version, f"v{version}"]:
134
- return release
135
- raise ValueError(f"Version {version} not found in releases")
136
-
137
-
138
- def download_release_asset_zip(release: Release, destination_path: str) -> None:
139
- """Download dist.zip from github release."""
140
- asset_url = None
141
- for asset in release.get("assets", []):
142
- if asset["name"] == "dist.zip":
143
- asset_url = asset["url"]
144
- break
145
-
146
- if not asset_url:
147
- raise ValueError("dist.zip not found in the release assets")
148
-
149
- # Use a temporary file to download the zip content
150
- with tempfile.TemporaryFile() as tmp_file:
151
- headers = {"Accept": "application/octet-stream"}
152
- response = requests.get(
153
- asset_url, headers=headers, allow_redirects=True, timeout=REQUEST_TIMEOUT
154
- )
155
- response.raise_for_status() # Ensure we got a successful response
156
-
157
- # Write the content to the temporary file
158
- tmp_file.write(response.content)
159
-
160
- # Go back to the beginning of the temporary file
161
- tmp_file.seek(0)
162
-
163
- # Extract the zip file content to the destination path
164
- with zipfile.ZipFile(tmp_file, "r") as zip_ref:
165
- zip_ref.extractall(destination_path)
166
-
167
-
168
- class FrontendManager:
169
- CUSTOM_FRONTENDS_ROOT = str(Path(__file__).parents[1] / "web_custom_versions")
170
-
171
- @classmethod
172
- def default_frontend_path(cls) -> str:
173
- try:
174
- import comfyui_frontend_package
175
-
176
- return str(importlib.resources.files(comfyui_frontend_package) / "static")
177
- except ImportError:
178
- logging.error(
179
- f"""
180
- ********** ERROR ***********
181
-
182
- comfyui-frontend-package is not installed.
183
-
184
- {frontend_install_warning_message()}
185
-
186
- ********** ERROR ***********
187
- """.strip()
188
- )
189
- sys.exit(-1)
190
-
191
- @classmethod
192
- def templates_path(cls) -> str:
193
- try:
194
- import comfyui_workflow_templates
195
-
196
- return str(
197
- importlib.resources.files(comfyui_workflow_templates) / "templates"
198
- )
199
- except ImportError:
200
- logging.error(
201
- f"""
202
- ********** ERROR ***********
203
-
204
- comfyui-workflow-templates is not installed.
205
-
206
- {frontend_install_warning_message()}
207
-
208
- ********** ERROR ***********
209
- """.strip()
210
- )
211
-
212
- @classmethod
213
- def embedded_docs_path(cls) -> str:
214
- """Get the path to embedded documentation"""
215
- try:
216
- import comfyui_embedded_docs
217
-
218
- return str(
219
- importlib.resources.files(comfyui_embedded_docs) / "docs"
220
- )
221
- except ImportError:
222
- logging.info("comfyui-embedded-docs package not found")
223
- return None
224
-
225
- @classmethod
226
- def parse_version_string(cls, value: str) -> tuple[str, str, str]:
227
- """
228
- Args:
229
- value (str): The version string to parse.
230
-
231
- Returns:
232
- tuple[str, str]: A tuple containing provider name and version.
233
-
234
- Raises:
235
- argparse.ArgumentTypeError: If the version string is invalid.
236
- """
237
- VERSION_PATTERN = r"^([a-zA-Z0-9][a-zA-Z0-9-]{0,38})/([a-zA-Z0-9_.-]+)@(v?\d+\.\d+\.\d+[-._a-zA-Z0-9]*|latest|prerelease)$"
238
- match_result = re.match(VERSION_PATTERN, value)
239
- if match_result is None:
240
- raise argparse.ArgumentTypeError(f"Invalid version string: {value}")
241
-
242
- return match_result.group(1), match_result.group(2), match_result.group(3)
243
-
244
- @classmethod
245
- def init_frontend_unsafe(
246
- cls, version_string: str, provider: Optional[FrontEndProvider] = None
247
- ) -> str:
248
- """
249
- Initializes the frontend for the specified version.
250
-
251
- Args:
252
- version_string (str): The version string.
253
- provider (FrontEndProvider, optional): The provider to use. Defaults to None.
254
-
255
- Returns:
256
- str: The path to the initialized frontend.
257
-
258
- Raises:
259
- Exception: If there is an error during the initialization process.
260
- main error source might be request timeout or invalid URL.
261
- """
262
- if version_string == DEFAULT_VERSION_STRING:
263
- check_frontend_version()
264
- return cls.default_frontend_path()
265
-
266
- repo_owner, repo_name, version = cls.parse_version_string(version_string)
267
-
268
- if version.startswith("v"):
269
- expected_path = str(
270
- Path(cls.CUSTOM_FRONTENDS_ROOT)
271
- / f"{repo_owner}_{repo_name}"
272
- / version.lstrip("v")
273
- )
274
- if os.path.exists(expected_path):
275
- logging.info(
276
- f"Using existing copy of specific frontend version tag: {repo_owner}/{repo_name}@{version}"
277
- )
278
- return expected_path
279
-
280
- logging.info(
281
- f"Initializing frontend: {repo_owner}/{repo_name}@{version}, requesting version details from GitHub..."
282
- )
283
-
284
- provider = provider or FrontEndProvider(repo_owner, repo_name)
285
- release = provider.get_release(version)
286
-
287
- semantic_version = release["tag_name"].lstrip("v")
288
- web_root = str(
289
- Path(cls.CUSTOM_FRONTENDS_ROOT) / provider.folder_name / semantic_version
290
- )
291
- if not os.path.exists(web_root):
292
- try:
293
- os.makedirs(web_root, exist_ok=True)
294
- logging.info(
295
- "Downloading frontend(%s) version(%s) to (%s)",
296
- provider.folder_name,
297
- semantic_version,
298
- web_root,
299
- )
300
- logging.debug(release)
301
- download_release_asset_zip(release, destination_path=web_root)
302
- finally:
303
- # Clean up the directory if it is empty, i.e. the download failed
304
- if not os.listdir(web_root):
305
- os.rmdir(web_root)
306
-
307
- return web_root
308
-
309
- @classmethod
310
- def init_frontend(cls, version_string: str) -> str:
311
- """
312
- Initializes the frontend with the specified version string.
313
-
314
- Args:
315
- version_string (str): The version string to initialize the frontend with.
316
-
317
- Returns:
318
- str: The path of the initialized frontend.
319
- """
320
- try:
321
- return cls.init_frontend_unsafe(version_string)
322
- except Exception as e:
323
- logging.error("Failed to initialize frontend: %s", e)
324
- logging.info("Falling back to the default frontend.")
325
- check_frontend_version()
326
- return cls.default_frontend_path()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/logger.py DELETED
@@ -1,98 +0,0 @@
1
- from collections import deque
2
- from datetime import datetime
3
- import io
4
- import logging
5
- import sys
6
- import threading
7
-
8
- logs = None
9
- stdout_interceptor = None
10
- stderr_interceptor = None
11
-
12
-
13
- class LogInterceptor(io.TextIOWrapper):
14
- def __init__(self, stream, *args, **kwargs):
15
- buffer = stream.buffer
16
- encoding = stream.encoding
17
- super().__init__(buffer, *args, **kwargs, encoding=encoding, line_buffering=stream.line_buffering)
18
- self._lock = threading.Lock()
19
- self._flush_callbacks = []
20
- self._logs_since_flush = []
21
-
22
- def write(self, data):
23
- entry = {"t": datetime.now().isoformat(), "m": data}
24
- with self._lock:
25
- self._logs_since_flush.append(entry)
26
-
27
- # Simple handling for cr to overwrite the last output if it isnt a full line
28
- # else logs just get full of progress messages
29
- if isinstance(data, str) and data.startswith("\r") and not logs[-1]["m"].endswith("\n"):
30
- logs.pop()
31
- logs.append(entry)
32
- super().write(data)
33
-
34
- def flush(self):
35
- super().flush()
36
- for cb in self._flush_callbacks:
37
- cb(self._logs_since_flush)
38
- self._logs_since_flush = []
39
-
40
- def on_flush(self, callback):
41
- self._flush_callbacks.append(callback)
42
-
43
-
44
- def get_logs():
45
- return logs
46
-
47
-
48
- def on_flush(callback):
49
- if stdout_interceptor is not None:
50
- stdout_interceptor.on_flush(callback)
51
- if stderr_interceptor is not None:
52
- stderr_interceptor.on_flush(callback)
53
-
54
- def setup_logger(log_level: str = 'INFO', capacity: int = 300, use_stdout: bool = False):
55
- global logs
56
- if logs:
57
- return
58
-
59
- # Override output streams and log to buffer
60
- logs = deque(maxlen=capacity)
61
-
62
- global stdout_interceptor
63
- global stderr_interceptor
64
- stdout_interceptor = sys.stdout = LogInterceptor(sys.stdout)
65
- stderr_interceptor = sys.stderr = LogInterceptor(sys.stderr)
66
-
67
- # Setup default global logger
68
- logger = logging.getLogger()
69
- logger.setLevel(log_level)
70
-
71
- stream_handler = logging.StreamHandler()
72
- stream_handler.setFormatter(logging.Formatter("%(message)s"))
73
-
74
- if use_stdout:
75
- # Only errors and critical to stderr
76
- stream_handler.addFilter(lambda record: not record.levelno < logging.ERROR)
77
-
78
- # Lesser to stdout
79
- stdout_handler = logging.StreamHandler(sys.stdout)
80
- stdout_handler.setFormatter(logging.Formatter("%(message)s"))
81
- stdout_handler.addFilter(lambda record: record.levelno < logging.ERROR)
82
- logger.addHandler(stdout_handler)
83
-
84
- logger.addHandler(stream_handler)
85
-
86
-
87
- STARTUP_WARNINGS = []
88
-
89
-
90
- def log_startup_warning(msg):
91
- logging.warning(msg)
92
- STARTUP_WARNINGS.append(msg)
93
-
94
-
95
- def print_startup_warnings():
96
- for s in STARTUP_WARNINGS:
97
- logging.warning(s)
98
- STARTUP_WARNINGS.clear()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/model_manager.py DELETED
@@ -1,184 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import base64
5
- import json
6
- import time
7
- import logging
8
- import folder_paths
9
- import glob
10
- import comfy.utils
11
- from aiohttp import web
12
- from PIL import Image
13
- from io import BytesIO
14
- from folder_paths import map_legacy, filter_files_extensions, filter_files_content_types
15
-
16
-
17
- class ModelFileManager:
18
- def __init__(self) -> None:
19
- self.cache: dict[str, tuple[list[dict], dict[str, float], float]] = {}
20
-
21
- def get_cache(self, key: str, default=None) -> tuple[list[dict], dict[str, float], float] | None:
22
- return self.cache.get(key, default)
23
-
24
- def set_cache(self, key: str, value: tuple[list[dict], dict[str, float], float]):
25
- self.cache[key] = value
26
-
27
- def clear_cache(self):
28
- self.cache.clear()
29
-
30
- def add_routes(self, routes):
31
- # NOTE: This is an experiment to replace `/models`
32
- @routes.get("/experiment/models")
33
- async def get_model_folders(request):
34
- model_types = list(folder_paths.folder_names_and_paths.keys())
35
- folder_black_list = ["configs", "custom_nodes"]
36
- output_folders: list[dict] = []
37
- for folder in model_types:
38
- if folder in folder_black_list:
39
- continue
40
- output_folders.append({"name": folder, "folders": folder_paths.get_folder_paths(folder)})
41
- return web.json_response(output_folders)
42
-
43
- # NOTE: This is an experiment to replace `/models/{folder}`
44
- @routes.get("/experiment/models/{folder}")
45
- async def get_all_models(request):
46
- folder = request.match_info.get("folder", None)
47
- if not folder in folder_paths.folder_names_and_paths:
48
- return web.Response(status=404)
49
- files = self.get_model_file_list(folder)
50
- return web.json_response(files)
51
-
52
- @routes.get("/experiment/models/preview/{folder}/{path_index}/{filename:.*}")
53
- async def get_model_preview(request):
54
- folder_name = request.match_info.get("folder", None)
55
- path_index = int(request.match_info.get("path_index", None))
56
- filename = request.match_info.get("filename", None)
57
-
58
- if not folder_name in folder_paths.folder_names_and_paths:
59
- return web.Response(status=404)
60
-
61
- folders = folder_paths.folder_names_and_paths[folder_name]
62
- folder = folders[0][path_index]
63
- full_filename = os.path.join(folder, filename)
64
-
65
- previews = self.get_model_previews(full_filename)
66
- default_preview = previews[0] if len(previews) > 0 else None
67
- if default_preview is None or (isinstance(default_preview, str) and not os.path.isfile(default_preview)):
68
- return web.Response(status=404)
69
-
70
- try:
71
- with Image.open(default_preview) as img:
72
- img_bytes = BytesIO()
73
- img.save(img_bytes, format="WEBP")
74
- img_bytes.seek(0)
75
- return web.Response(body=img_bytes.getvalue(), content_type="image/webp")
76
- except:
77
- return web.Response(status=404)
78
-
79
- def get_model_file_list(self, folder_name: str):
80
- folder_name = map_legacy(folder_name)
81
- folders = folder_paths.folder_names_and_paths[folder_name]
82
- output_list: list[dict] = []
83
-
84
- for index, folder in enumerate(folders[0]):
85
- if not os.path.isdir(folder):
86
- continue
87
- out = self.cache_model_file_list_(folder)
88
- if out is None:
89
- out = self.recursive_search_models_(folder, index)
90
- self.set_cache(folder, out)
91
- output_list.extend(out[0])
92
-
93
- return output_list
94
-
95
- def cache_model_file_list_(self, folder: str):
96
- model_file_list_cache = self.get_cache(folder)
97
-
98
- if model_file_list_cache is None:
99
- return None
100
- if not os.path.isdir(folder):
101
- return None
102
- if os.path.getmtime(folder) != model_file_list_cache[1]:
103
- return None
104
- for x in model_file_list_cache[1]:
105
- time_modified = model_file_list_cache[1][x]
106
- folder = x
107
- if os.path.getmtime(folder) != time_modified:
108
- return None
109
-
110
- return model_file_list_cache
111
-
112
- def recursive_search_models_(self, directory: str, pathIndex: int) -> tuple[list[str], dict[str, float], float]:
113
- if not os.path.isdir(directory):
114
- return [], {}, time.perf_counter()
115
-
116
- excluded_dir_names = [".git"]
117
- # TODO use settings
118
- include_hidden_files = False
119
-
120
- result: list[str] = []
121
- dirs: dict[str, float] = {}
122
-
123
- for dirpath, subdirs, filenames in os.walk(directory, followlinks=True, topdown=True):
124
- subdirs[:] = [d for d in subdirs if d not in excluded_dir_names]
125
- if not include_hidden_files:
126
- subdirs[:] = [d for d in subdirs if not d.startswith(".")]
127
- filenames = [f for f in filenames if not f.startswith(".")]
128
-
129
- filenames = filter_files_extensions(filenames, folder_paths.supported_pt_extensions)
130
-
131
- for file_name in filenames:
132
- try:
133
- relative_path = os.path.relpath(os.path.join(dirpath, file_name), directory)
134
- result.append(relative_path)
135
- except:
136
- logging.warning(f"Warning: Unable to access {file_name}. Skipping this file.")
137
- continue
138
-
139
- for d in subdirs:
140
- path: str = os.path.join(dirpath, d)
141
- try:
142
- dirs[path] = os.path.getmtime(path)
143
- except FileNotFoundError:
144
- logging.warning(f"Warning: Unable to access {path}. Skipping this path.")
145
- continue
146
-
147
- return [{"name": f, "pathIndex": pathIndex} for f in result], dirs, time.perf_counter()
148
-
149
- def get_model_previews(self, filepath: str) -> list[str | BytesIO]:
150
- dirname = os.path.dirname(filepath)
151
-
152
- if not os.path.exists(dirname):
153
- return []
154
-
155
- basename = os.path.splitext(filepath)[0]
156
- match_files = glob.glob(f"{basename}.*", recursive=False)
157
- image_files = filter_files_content_types(match_files, "image")
158
- safetensors_file = next(filter(lambda x: x.endswith(".safetensors"), match_files), None)
159
- safetensors_metadata = {}
160
-
161
- result: list[str | BytesIO] = []
162
-
163
- for filename in image_files:
164
- _basename = os.path.splitext(filename)[0]
165
- if _basename == basename:
166
- result.append(filename)
167
- if _basename == f"{basename}.preview":
168
- result.append(filename)
169
-
170
- if safetensors_file:
171
- safetensors_filepath = os.path.join(dirname, safetensors_file)
172
- header = comfy.utils.safetensors_header(safetensors_filepath, max_size=8*1024*1024)
173
- if header:
174
- safetensors_metadata = json.loads(header)
175
- safetensors_images = safetensors_metadata.get("__metadata__", {}).get("ssmd_cover_images", None)
176
- if safetensors_images:
177
- safetensors_images = json.loads(safetensors_images)
178
- for image in safetensors_images:
179
- result.append(BytesIO(base64.b64decode(image)))
180
-
181
- return result
182
-
183
- def __exit__(self, exc_type, exc_value, traceback):
184
- self.clear_cache()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/user_manager.py DELETED
@@ -1,436 +0,0 @@
1
- from __future__ import annotations
2
- import json
3
- import os
4
- import re
5
- import uuid
6
- import glob
7
- import shutil
8
- import logging
9
- from aiohttp import web
10
- from urllib import parse
11
- from comfy.cli_args import args
12
- import folder_paths
13
- from .app_settings import AppSettings
14
- from typing import TypedDict
15
-
16
- default_user = "default"
17
-
18
-
19
- class FileInfo(TypedDict):
20
- path: str
21
- size: int
22
- modified: int
23
-
24
-
25
- def get_file_info(path: str, relative_to: str) -> FileInfo:
26
- return {
27
- "path": os.path.relpath(path, relative_to).replace(os.sep, '/'),
28
- "size": os.path.getsize(path),
29
- "modified": os.path.getmtime(path)
30
- }
31
-
32
-
33
- class UserManager():
34
- def __init__(self):
35
- user_directory = folder_paths.get_user_directory()
36
-
37
- self.settings = AppSettings(self)
38
- if not os.path.exists(user_directory):
39
- os.makedirs(user_directory, exist_ok=True)
40
- if not args.multi_user:
41
- logging.warning("****** User settings have been changed to be stored on the server instead of browser storage. ******")
42
- logging.warning("****** For multi-user setups add the --multi-user CLI argument to enable multiple user profiles. ******")
43
-
44
- if args.multi_user:
45
- if os.path.isfile(self.get_users_file()):
46
- with open(self.get_users_file()) as f:
47
- self.users = json.load(f)
48
- else:
49
- self.users = {}
50
- else:
51
- self.users = {"default": "default"}
52
-
53
- def get_users_file(self):
54
- return os.path.join(folder_paths.get_user_directory(), "users.json")
55
-
56
- def get_request_user_id(self, request):
57
- user = "default"
58
- if args.multi_user and "comfy-user" in request.headers:
59
- user = request.headers["comfy-user"]
60
-
61
- if user not in self.users:
62
- raise KeyError("Unknown user: " + user)
63
-
64
- return user
65
-
66
- def get_request_user_filepath(self, request, file, type="userdata", create_dir=True):
67
- user_directory = folder_paths.get_user_directory()
68
-
69
- if type == "userdata":
70
- root_dir = user_directory
71
- else:
72
- raise KeyError("Unknown filepath type:" + type)
73
-
74
- user = self.get_request_user_id(request)
75
- path = user_root = os.path.abspath(os.path.join(root_dir, user))
76
-
77
- # prevent leaving /{type}
78
- if os.path.commonpath((root_dir, user_root)) != root_dir:
79
- return None
80
-
81
- if file is not None:
82
- # Check if filename is url encoded
83
- if "%" in file:
84
- file = parse.unquote(file)
85
-
86
- # prevent leaving /{type}/{user}
87
- path = os.path.abspath(os.path.join(user_root, file))
88
- if os.path.commonpath((user_root, path)) != user_root:
89
- return None
90
-
91
- parent = os.path.split(path)[0]
92
-
93
- if create_dir and not os.path.exists(parent):
94
- os.makedirs(parent, exist_ok=True)
95
-
96
- return path
97
-
98
- def add_user(self, name):
99
- name = name.strip()
100
- if not name:
101
- raise ValueError("username not provided")
102
- user_id = re.sub("[^a-zA-Z0-9-_]+", '-', name)
103
- user_id = user_id + "_" + str(uuid.uuid4())
104
-
105
- self.users[user_id] = name
106
-
107
- with open(self.get_users_file(), "w") as f:
108
- json.dump(self.users, f)
109
-
110
- return user_id
111
-
112
- def add_routes(self, routes):
113
- self.settings.add_routes(routes)
114
-
115
- @routes.get("/users")
116
- async def get_users(request):
117
- if args.multi_user:
118
- return web.json_response({"storage": "server", "users": self.users})
119
- else:
120
- user_dir = self.get_request_user_filepath(request, None, create_dir=False)
121
- return web.json_response({
122
- "storage": "server",
123
- "migrated": os.path.exists(user_dir)
124
- })
125
-
126
- @routes.post("/users")
127
- async def post_users(request):
128
- body = await request.json()
129
- username = body["username"]
130
- if username in self.users.values():
131
- return web.json_response({"error": "Duplicate username."}, status=400)
132
-
133
- user_id = self.add_user(username)
134
- return web.json_response(user_id)
135
-
136
- @routes.get("/userdata")
137
- async def listuserdata(request):
138
- """
139
- List user data files in a specified directory.
140
-
141
- This endpoint allows listing files in a user's data directory, with options for recursion,
142
- full file information, and path splitting.
143
-
144
- Query Parameters:
145
- - dir (required): The directory to list files from.
146
- - recurse (optional): If "true", recursively list files in subdirectories.
147
- - full_info (optional): If "true", return detailed file information (path, size, modified time).
148
- - split (optional): If "true", split file paths into components (only applies when full_info is false).
149
-
150
- Returns:
151
- - 400: If 'dir' parameter is missing.
152
- - 403: If the requested path is not allowed.
153
- - 404: If the requested directory does not exist.
154
- - 200: JSON response with the list of files or file information.
155
-
156
- The response format depends on the query parameters:
157
- - Default: List of relative file paths.
158
- - full_info=true: List of dictionaries with file details.
159
- - split=true (and full_info=false): List of lists, each containing path components.
160
- """
161
- directory = request.rel_url.query.get('dir', '')
162
- if not directory:
163
- return web.Response(status=400, text="Directory not provided")
164
-
165
- path = self.get_request_user_filepath(request, directory)
166
- if not path:
167
- return web.Response(status=403, text="Invalid directory")
168
-
169
- if not os.path.exists(path):
170
- return web.Response(status=404, text="Directory not found")
171
-
172
- recurse = request.rel_url.query.get('recurse', '').lower() == "true"
173
- full_info = request.rel_url.query.get('full_info', '').lower() == "true"
174
- split_path = request.rel_url.query.get('split', '').lower() == "true"
175
-
176
- # Use different patterns based on whether we're recursing or not
177
- if recurse:
178
- pattern = os.path.join(glob.escape(path), '**', '*')
179
- else:
180
- pattern = os.path.join(glob.escape(path), '*')
181
-
182
- def process_full_path(full_path: str) -> FileInfo | str | list[str]:
183
- if full_info:
184
- return get_file_info(full_path, path)
185
-
186
- rel_path = os.path.relpath(full_path, path).replace(os.sep, '/')
187
- if split_path:
188
- return [rel_path] + rel_path.split('/')
189
-
190
- return rel_path
191
-
192
- results = [
193
- process_full_path(full_path)
194
- for full_path in glob.glob(pattern, recursive=recurse)
195
- if os.path.isfile(full_path)
196
- ]
197
-
198
- return web.json_response(results)
199
-
200
- @routes.get("/v2/userdata")
201
- async def list_userdata_v2(request):
202
- """
203
- List files and directories in a user's data directory.
204
-
205
- This endpoint provides a structured listing of contents within a specified
206
- subdirectory of the user's data storage.
207
-
208
- Query Parameters:
209
- - path (optional): The relative path within the user's data directory
210
- to list. Defaults to the root ('').
211
-
212
- Returns:
213
- - 400: If the requested path is invalid, outside the user's data directory, or is not a directory.
214
- - 404: If the requested path does not exist.
215
- - 403: If the user is invalid.
216
- - 500: If there is an error reading the directory contents.
217
- - 200: JSON response containing a list of file and directory objects.
218
- Each object includes:
219
- - name: The name of the file or directory.
220
- - type: 'file' or 'directory'.
221
- - path: The relative path from the user's data root.
222
- - size (for files): The size in bytes.
223
- - modified (for files): The last modified timestamp (Unix epoch).
224
- """
225
- requested_rel_path = request.rel_url.query.get('path', '')
226
-
227
- # URL-decode the path parameter
228
- try:
229
- requested_rel_path = parse.unquote(requested_rel_path)
230
- except Exception as e:
231
- logging.warning(f"Failed to decode path parameter: {requested_rel_path}, Error: {e}")
232
- return web.Response(status=400, text="Invalid characters in path parameter")
233
-
234
-
235
- # Check user validity and get the absolute path for the requested directory
236
- try:
237
- base_user_path = self.get_request_user_filepath(request, None, create_dir=False)
238
-
239
- if requested_rel_path:
240
- target_abs_path = self.get_request_user_filepath(request, requested_rel_path, create_dir=False)
241
- else:
242
- target_abs_path = base_user_path
243
-
244
- except KeyError as e:
245
- # Invalid user detected by get_request_user_id inside get_request_user_filepath
246
- logging.warning(f"Access denied for user: {e}")
247
- return web.Response(status=403, text="Invalid user specified in request")
248
-
249
-
250
- if not target_abs_path:
251
- # Path traversal or other issue detected by get_request_user_filepath
252
- return web.Response(status=400, text="Invalid path requested")
253
-
254
- # Handle cases where the user directory or target path doesn't exist
255
- if not os.path.exists(target_abs_path):
256
- # Check if it's the base user directory that's missing (new user case)
257
- if target_abs_path == base_user_path:
258
- # It's okay if the base user directory doesn't exist yet, return empty list
259
- return web.json_response([])
260
- else:
261
- # A specific subdirectory was requested but doesn't exist
262
- return web.Response(status=404, text="Requested path not found")
263
-
264
- if not os.path.isdir(target_abs_path):
265
- return web.Response(status=400, text="Requested path is not a directory")
266
-
267
- results = []
268
- try:
269
- for root, dirs, files in os.walk(target_abs_path, topdown=True):
270
- # Process directories
271
- for dir_name in dirs:
272
- dir_path = os.path.join(root, dir_name)
273
- rel_path = os.path.relpath(dir_path, base_user_path).replace(os.sep, '/')
274
- results.append({
275
- "name": dir_name,
276
- "path": rel_path,
277
- "type": "directory"
278
- })
279
-
280
- # Process files
281
- for file_name in files:
282
- file_path = os.path.join(root, file_name)
283
- rel_path = os.path.relpath(file_path, base_user_path).replace(os.sep, '/')
284
- entry_info = {
285
- "name": file_name,
286
- "path": rel_path,
287
- "type": "file"
288
- }
289
- try:
290
- stats = os.stat(file_path) # Use os.stat for potentially better performance with os.walk
291
- entry_info["size"] = stats.st_size
292
- entry_info["modified"] = stats.st_mtime
293
- except OSError as stat_error:
294
- logging.warning(f"Could not stat file {file_path}: {stat_error}")
295
- pass # Include file with available info
296
- results.append(entry_info)
297
- except OSError as e:
298
- logging.error(f"Error listing directory {target_abs_path}: {e}")
299
- return web.Response(status=500, text="Error reading directory contents")
300
-
301
- # Sort results alphabetically, directories first then files
302
- results.sort(key=lambda x: (x['type'] != 'directory', x['name'].lower()))
303
-
304
- return web.json_response(results)
305
-
306
- def get_user_data_path(request, check_exists = False, param = "file"):
307
- file = request.match_info.get(param, None)
308
- if not file:
309
- return web.Response(status=400)
310
-
311
- path = self.get_request_user_filepath(request, file)
312
- if not path:
313
- return web.Response(status=403)
314
-
315
- if check_exists and not os.path.exists(path):
316
- return web.Response(status=404)
317
-
318
- return path
319
-
320
- @routes.get("/userdata/{file}")
321
- async def getuserdata(request):
322
- path = get_user_data_path(request, check_exists=True)
323
- if not isinstance(path, str):
324
- return path
325
-
326
- return web.FileResponse(path)
327
-
328
- @routes.post("/userdata/{file}")
329
- async def post_userdata(request):
330
- """
331
- Upload or update a user data file.
332
-
333
- This endpoint handles file uploads to a user's data directory, with options for
334
- controlling overwrite behavior and response format.
335
-
336
- Query Parameters:
337
- - overwrite (optional): If "false", prevents overwriting existing files. Defaults to "true".
338
- - full_info (optional): If "true", returns detailed file information (path, size, modified time).
339
- If "false", returns only the relative file path.
340
-
341
- Path Parameters:
342
- - file: The target file path (URL encoded if necessary).
343
-
344
- Returns:
345
- - 400: If 'file' parameter is missing.
346
- - 403: If the requested path is not allowed.
347
- - 409: If overwrite=false and the file already exists.
348
- - 200: JSON response with either:
349
- - Full file information (if full_info=true)
350
- - Relative file path (if full_info=false)
351
-
352
- The request body should contain the raw file content to be written.
353
- """
354
- path = get_user_data_path(request)
355
- if not isinstance(path, str):
356
- return path
357
-
358
- overwrite = request.query.get("overwrite", 'true') != "false"
359
- full_info = request.query.get('full_info', 'false').lower() == "true"
360
-
361
- if not overwrite and os.path.exists(path):
362
- return web.Response(status=409, text="File already exists")
363
-
364
- body = await request.read()
365
-
366
- with open(path, "wb") as f:
367
- f.write(body)
368
-
369
- user_path = self.get_request_user_filepath(request, None)
370
- if full_info:
371
- resp = get_file_info(path, user_path)
372
- else:
373
- resp = os.path.relpath(path, user_path)
374
-
375
- return web.json_response(resp)
376
-
377
- @routes.delete("/userdata/{file}")
378
- async def delete_userdata(request):
379
- path = get_user_data_path(request, check_exists=True)
380
- if not isinstance(path, str):
381
- return path
382
-
383
- os.remove(path)
384
-
385
- return web.Response(status=204)
386
-
387
- @routes.post("/userdata/{file}/move/{dest}")
388
- async def move_userdata(request):
389
- """
390
- Move or rename a user data file.
391
-
392
- This endpoint handles moving or renaming files within a user's data directory, with options for
393
- controlling overwrite behavior and response format.
394
-
395
- Path Parameters:
396
- - file: The source file path (URL encoded if necessary)
397
- - dest: The destination file path (URL encoded if necessary)
398
-
399
- Query Parameters:
400
- - overwrite (optional): If "false", prevents overwriting existing files. Defaults to "true".
401
- - full_info (optional): If "true", returns detailed file information (path, size, modified time).
402
- If "false", returns only the relative file path.
403
-
404
- Returns:
405
- - 400: If either 'file' or 'dest' parameter is missing
406
- - 403: If either requested path is not allowed
407
- - 404: If the source file does not exist
408
- - 409: If overwrite=false and the destination file already exists
409
- - 200: JSON response with either:
410
- - Full file information (if full_info=true)
411
- - Relative file path (if full_info=false)
412
- """
413
- source = get_user_data_path(request, check_exists=True)
414
- if not isinstance(source, str):
415
- return source
416
-
417
- dest = get_user_data_path(request, check_exists=False, param="dest")
418
- if not isinstance(source, str):
419
- return dest
420
-
421
- overwrite = request.query.get("overwrite", 'true') != "false"
422
- full_info = request.query.get('full_info', 'false').lower() == "true"
423
-
424
- if not overwrite and os.path.exists(dest):
425
- return web.Response(status=409, text="File already exists")
426
-
427
- logging.info(f"moving '{source}' -> '{dest}'")
428
- shutil.move(source, dest)
429
-
430
- user_path = self.get_request_user_filepath(request, None)
431
- if full_info:
432
- resp = get_file_info(dest, user_path)
433
- else:
434
- resp = os.path.relpath(dest, user_path)
435
-
436
- return web.json_response(resp)