Skip to content

Commit

Permalink
Store user settings/data on the server and multi user support (#2160)
Browse files Browse the repository at this point in the history
* wip per user data

* Rename, hide menu

* better error
rework default user

* store pretty

* Add userdata endpoints
Change nodetemplates to userdata

* add multi user message

* make normal arg

* Fix tests

* Ignore user dir

* user tests

* Changed to default to browser storage and add server-storage arg

* fix crash on empty templates

* fix settings added before load

* ignore parse errors
  • Loading branch information
pythongosssss committed Jan 8, 2024
1 parent 6a10640 commit 235727f
Show file tree
Hide file tree
Showing 25 changed files with 1,496 additions and 282 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ venv/
/web/extensions/*
!/web/extensions/logging.js.example
!/web/extensions/core/
/tests-ui/data/object_info.json
/tests-ui/data/object_info.json
/user/
54 changes: 54 additions & 0 deletions app/app_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import os
import json
from aiohttp import web


class AppSettings():
def __init__(self, user_manager):
self.user_manager = user_manager

def get_settings(self, request):
file = self.user_manager.get_request_user_filepath(
request, "comfy.settings.json")
if os.path.isfile(file):
with open(file) as f:
return json.load(f)
else:
return {}

def save_settings(self, request, settings):
file = self.user_manager.get_request_user_filepath(
request, "comfy.settings.json")
with open(file, "w") as f:
f.write(json.dumps(settings, indent=4))

def add_routes(self, routes):
@routes.get("/settings")
async def get_settings(request):
return web.json_response(self.get_settings(request))

@routes.get("/settings/{id}")
async def get_setting(request):
value = None
settings = self.get_settings(request)
setting_id = request.match_info.get("id", None)
if setting_id and setting_id in settings:
value = settings[setting_id]
return web.json_response(value)

@routes.post("/settings")
async def post_settings(request):
settings = self.get_settings(request)
new_settings = await request.json()
self.save_settings(request, {**settings, **new_settings})
return web.Response(status=200)

@routes.post("/settings/{id}")
async def post_setting(request):
setting_id = request.match_info.get("id", None)
if not setting_id:
return web.Response(status=400)
settings = self.get_settings(request)
settings[setting_id] = await request.json()
self.save_settings(request, settings)
return web.Response(status=200)
141 changes: 141 additions & 0 deletions app/user_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import json
import os
import re
import uuid
from aiohttp import web
from comfy.cli_args import args
from folder_paths import user_directory
from .app_settings import AppSettings

default_user = "default"
users_file = os.path.join(user_directory, "users.json")


class UserManager():
def __init__(self):
global user_directory

self.settings = AppSettings(self)
if not os.path.exists(user_directory):
os.mkdir(user_directory)
if not args.multi_user:
print("****** User settings have been changed to be stored on the server instead of browser storage. ******")
print("****** For multi-user setups add the --multi-user CLI argument to enable multiple user profiles. ******")

if args.multi_user:
if os.path.isfile(users_file):
with open(users_file) as f:
self.users = json.load(f)
else:
self.users = {}
else:
self.users = {"default": "default"}

def get_request_user_id(self, request):
user = "default"
if args.multi_user and "comfy-user" in request.headers:
user = request.headers["comfy-user"]

if user not in self.users:
raise KeyError("Unknown user: " + user)

return user

def get_request_user_filepath(self, request, file, type="userdata", create_dir=True):
global user_directory

if type == "userdata":
root_dir = user_directory
else:
raise KeyError("Unknown filepath type:" + type)

user = self.get_request_user_id(request)
path = user_root = os.path.abspath(os.path.join(root_dir, user))

# prevent leaving /{type}
if os.path.commonpath((root_dir, user_root)) != root_dir:
return None

parent = user_root

if file is not None:
# prevent leaving /{type}/{user}
path = os.path.abspath(os.path.join(user_root, file))
if os.path.commonpath((user_root, path)) != user_root:
return None
parent = os.path.join(path, os.pardir)

if create_dir and not os.path.exists(parent):
os.mkdir(parent)

return path

def add_user(self, name):
name = name.strip()
if not name:
raise ValueError("username not provided")
user_id = re.sub("[^a-zA-Z0-9-_]+", '-', name)
user_id = user_id + "_" + str(uuid.uuid4())

self.users[user_id] = name

global users_file
with open(users_file, "w") as f:
json.dump(self.users, f)

return user_id

def add_routes(self, routes):
self.settings.add_routes(routes)

@routes.get("/users")
async def get_users(request):
if args.multi_user:
return web.json_response({"storage": "server", "users": self.users})
else:
user_dir = self.get_request_user_filepath(request, None, create_dir=False)
return web.json_response({
"storage": "server" if args.server_storage else "browser",
"migrated": os.path.exists(user_dir)
})

@routes.post("/users")
async def post_users(request):
body = await request.json()
username = body["username"]
if username in self.users.values():
return web.json_response({"error": "Duplicate username."}, status=400)

user_id = self.add_user(username)
return web.json_response(user_id)

@routes.get("/userdata/{file}")
async def getuserdata(request):
file = request.match_info.get("file", None)
if not file:
return web.Response(status=400)

path = self.get_request_user_filepath(request, file)
if not path:
return web.Response(status=403)

if not os.path.exists(path):
return web.Response(status=404)

return web.FileResponse(path)

@routes.post("/userdata/{file}")
async def post_userdata(request):
file = request.match_info.get("file", None)
if not file:
return web.Response(status=400)

path = self.get_request_user_filepath(request, file)
if not path:
return web.Response(status=403)

body = await request.read()
with open(path, "wb") as f:
f.write(body)

return web.Response(status=200)
6 changes: 6 additions & 0 deletions comfy/cli_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ class LatentPreviewMethod(enum.Enum):

parser.add_argument("--disable-metadata", action="store_true", help="Disable saving prompt metadata in files.")

parser.add_argument("--server-storage", action="store_true", help="Saves settings and other user configuration on the server instead of in browser storage.")
parser.add_argument("--multi-user", action="store_true", help="Enables per-user storage. If enabled, server-storage will be unconditionally enabled.")

if comfy.options.args_parsing:
args = parser.parse_args()
else:
Expand All @@ -122,3 +125,6 @@ class LatentPreviewMethod(enum.Enum):

if args.disable_auto_launch:
args.auto_launch = False

if args.multi_user:
args.server_storage = True
1 change: 1 addition & 0 deletions folder_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
output_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "output")
temp_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "temp")
input_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "input")
user_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "user")

filename_list_cache = {}

Expand Down
3 changes: 3 additions & 0 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import comfy.utils
import comfy.model_management

from app.user_manager import UserManager

class BinaryEventTypes:
PREVIEW_IMAGE = 1
Expand Down Expand Up @@ -72,6 +73,7 @@ def __init__(self, loop):
mimetypes.init()
mimetypes.types_map['.js'] = 'application/javascript; charset=utf-8'

self.user_manager = UserManager()
self.supports = ["custom_nodes_from_web"]
self.prompt_queue = None
self.loop = loop
Expand Down Expand Up @@ -532,6 +534,7 @@ async def post_history(request):
return web.Response(status=200)

def add_routes(self):
self.user_manager.add_routes(self.routes)
self.app.add_routes(self.routes)

for name, dir in nodes.EXTENSION_WEB_DIRS.items():
Expand Down
3 changes: 2 additions & 1 deletion tests-ui/babel.config.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"presets": ["@babel/preset-env"]
"presets": ["@babel/preset-env"],
"plugins": ["babel-plugin-transform-import-meta"]
}
20 changes: 20 additions & 0 deletions tests-ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions tests-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"devDependencies": {
"@babel/preset-env": "^7.22.20",
"@types/jest": "^29.5.5",
"babel-plugin-transform-import-meta": "^2.2.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0"
}
Expand Down
Loading

0 comments on commit 235727f

Please sign in to comment.