hanakura 0.1.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.
- hanakura/__init__.py +33 -0
- hanakura/__main__.py +6 -0
- hanakura/api/__init__.py +1 -0
- hanakura/api/app.py +94 -0
- hanakura/api/deps.py +14 -0
- hanakura/api/errors.py +41 -0
- hanakura/api/openapi.py +29 -0
- hanakura/api/paths.py +33 -0
- hanakura/api/routers/__init__.py +1 -0
- hanakura/api/routers/app_info.py +59 -0
- hanakura/api/routers/auth.py +126 -0
- hanakura/api/routers/downloads.py +54 -0
- hanakura/api/routers/hubs.py +37 -0
- hanakura/api/routers/library.py +176 -0
- hanakura/api/routers/settings.py +45 -0
- hanakura/api/routers/sources.py +53 -0
- hanakura/api/security.py +125 -0
- hanakura/api/sockets.py +81 -0
- hanakura/api/static.py +49 -0
- hanakura/cli/__init__.py +1 -0
- hanakura/cli/app.py +110 -0
- hanakura/cli/commands/__init__.py +1 -0
- hanakura/cli/commands/config.py +47 -0
- hanakura/cli/commands/download.py +164 -0
- hanakura/cli/commands/library.py +255 -0
- hanakura/cli/commands/search.py +142 -0
- hanakura/cli/commands/system.py +48 -0
- hanakura/cli/commands/webui.py +75 -0
- hanakura/cli/factory.py +91 -0
- hanakura/cli/output.py +69 -0
- hanakura/core/__init__.py +1 -0
- hanakura/core/auth/__init__.py +7 -0
- hanakura/core/auth/models.py +54 -0
- hanakura/core/auth/oauth_client.py +156 -0
- hanakura/core/auth/service.py +282 -0
- hanakura/core/auth/store.py +220 -0
- hanakura/core/auth/transactions.py +89 -0
- hanakura/core/context.py +67 -0
- hanakura/core/db/__init__.py +5 -0
- hanakura/core/db/database.py +114 -0
- hanakura/core/detection/__init__.py +6 -0
- hanakura/core/detection/header.py +150 -0
- hanakura/core/detection/kinds.py +155 -0
- hanakura/core/detection/models.py +142 -0
- hanakura/core/detection/rules.py +121 -0
- hanakura/core/detection/rules_data/diffusion.json +162 -0
- hanakura/core/detection/rules_data/lora.json +82 -0
- hanakura/core/detection/rules_data/other.json +116 -0
- hanakura/core/detection/service.py +241 -0
- hanakura/core/downloads/__init__.py +1 -0
- hanakura/core/downloads/http_downloader.py +184 -0
- hanakura/core/downloads/hub_runner.py +166 -0
- hanakura/core/downloads/job.py +44 -0
- hanakura/core/downloads/manager.py +612 -0
- hanakura/core/downloads/models.py +84 -0
- hanakura/core/errors.py +75 -0
- hanakura/core/events/__init__.py +6 -0
- hanakura/core/events/bus.py +53 -0
- hanakura/core/events/models.py +78 -0
- hanakura/core/hubs/__init__.py +6 -0
- hanakura/core/hubs/base.py +82 -0
- hanakura/core/hubs/huggingface.py +89 -0
- hanakura/core/hubs/models.py +61 -0
- hanakura/core/hubs/modelscope.py +86 -0
- hanakura/core/hubs/registry.py +80 -0
- hanakura/core/hubs/worker.py +94 -0
- hanakura/core/library/__init__.py +5 -0
- hanakura/core/library/fsops.py +78 -0
- hanakura/core/library/layouts.py +129 -0
- hanakura/core/library/models.py +169 -0
- hanakura/core/library/previews.py +127 -0
- hanakura/core/library/safety.py +101 -0
- hanakura/core/library/service.py +765 -0
- hanakura/core/library/sidecar.py +111 -0
- hanakura/core/library/thumbnails.py +36 -0
- hanakura/core/net/__init__.py +1 -0
- hanakura/core/net/http.py +46 -0
- hanakura/core/net/ports.py +66 -0
- hanakura/core/net/runtime_file.py +51 -0
- hanakura/core/paths.py +22 -0
- hanakura/core/record.py +93 -0
- hanakura/core/settings/__init__.py +6 -0
- hanakura/core/settings/models.py +169 -0
- hanakura/core/settings/service.py +248 -0
- hanakura/core/sources/__init__.py +6 -0
- hanakura/core/sources/base.py +146 -0
- hanakura/core/sources/civitai.py +221 -0
- hanakura/core/sources/github_releases.py +114 -0
- hanakura/core/sources/models.py +126 -0
- hanakura/core/sources/openmodeldb.py +158 -0
- hanakura/core/sources/registry.py +150 -0
- hanakura/embed.py +315 -0
- hanakura/logger.py +24 -0
- hanakura/version.py +3 -0
- hanakura/webui/__init__.py +1 -0
- hanakura/webui/dist/assets/BrowseView-2dYJlaiI.css +1 -0
- hanakura/webui/dist/assets/BrowseView-BXdex7e5.js +1 -0
- hanakura/webui/dist/assets/DestinationPicker-BDMXAB5C.js +1 -0
- hanakura/webui/dist/assets/DestinationPicker-CbLw5jhe.css +1 -0
- hanakura/webui/dist/assets/DirectView-CXj1wivq.css +1 -0
- hanakura/webui/dist/assets/DirectView-DZHTCSX9.js +1 -0
- hanakura/webui/dist/assets/HubsView-BmbdFXQT.css +1 -0
- hanakura/webui/dist/assets/HubsView-ChcfqHE_.js +1 -0
- hanakura/webui/dist/assets/LibraryView-CnFSoQk0.js +2 -0
- hanakura/webui/dist/assets/LibraryView-D3YMPzUu.css +1 -0
- hanakura/webui/dist/assets/MarkdownContent-D-GEOQod.js +15 -0
- hanakura/webui/dist/assets/MarkdownContent-amdhRUML.css +1 -0
- hanakura/webui/dist/assets/ModelCard-BhnNNuMX.js +1 -0
- hanakura/webui/dist/assets/ModelCard-CI50BQS7.css +1 -0
- hanakura/webui/dist/assets/ModelGrid-D7_Fff78.css +1 -0
- hanakura/webui/dist/assets/ModelGrid-D_9yZqhT.js +1 -0
- hanakura/webui/dist/assets/SettingsView-8mrUyfmo.css +1 -0
- hanakura/webui/dist/assets/SettingsView-BsHugFUH.js +1 -0
- hanakura/webui/dist/assets/hubs-DvN83F-C.js +1 -0
- hanakura/webui/dist/assets/index-Bx38Yo7R.css +1 -0
- hanakura/webui/dist/assets/index-CKmtUUAb.js +2 -0
- hanakura/webui/dist/assets/library-BFPgbNr_.js +649 -0
- hanakura/webui/dist/assets/library-DHeT47RV.css +1 -0
- hanakura/webui/dist/assets/roboto-flex-cyrillic-ext-wght-normal-CVcCQn0y.woff2 +0 -0
- hanakura/webui/dist/assets/roboto-flex-cyrillic-wght-normal-BJ-nQEdz.woff2 +0 -0
- hanakura/webui/dist/assets/roboto-flex-greek-wght-normal-DeN4rzfy.woff2 +0 -0
- hanakura/webui/dist/assets/roboto-flex-latin-ext-wght-normal-gysdOMcS.woff2 +0 -0
- hanakura/webui/dist/assets/roboto-flex-latin-wght-normal-n-dP9qr2.woff2 +0 -0
- hanakura/webui/dist/assets/roboto-flex-vietnamese-wght-normal-AVFfiTln.woff2 +0 -0
- hanakura/webui/dist/assets/rolldown-runtime-DK3Fl9T5.js +1 -0
- hanakura/webui/dist/assets/sources-xkzDGvQZ.js +1 -0
- hanakura/webui/dist/assets/useInfiniteQuery-OhRo3zPv.js +1 -0
- hanakura/webui/dist/favicon.svg +1 -0
- hanakura/webui/dist/index.html +31 -0
- hanakura-0.1.0.dist-info/METADATA +383 -0
- hanakura-0.1.0.dist-info/RECORD +135 -0
- hanakura-0.1.0.dist-info/WHEEL +5 -0
- hanakura-0.1.0.dist-info/entry_points.txt +2 -0
- hanakura-0.1.0.dist-info/licenses/LICENSE +674 -0
- hanakura-0.1.0.dist-info/top_level.txt +1 -0
hanakura/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Hanakura: download and manage Stable Diffusion models.
|
|
2
|
+
|
|
3
|
+
To embed the web UI and API in another application:
|
|
4
|
+
|
|
5
|
+
from hanakura import HanakuraServer, ModelRoot
|
|
6
|
+
|
|
7
|
+
hub = HanakuraServer(model_roots=[ModelRoot("/srv/models", layout="comfyui")], port=0)
|
|
8
|
+
print(hub.start()) # http://127.0.0.1:54123
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from hanakura.version import VERSION
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from hanakura.embed import HanakuraServer, ModelRoot, serve
|
|
17
|
+
|
|
18
|
+
__all__ = ["VERSION", "HanakuraServer", "ModelRoot", "serve"]
|
|
19
|
+
|
|
20
|
+
_LAZY = {"HanakuraServer", "ModelRoot", "serve"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def __getattr__(name: str) -> Any:
|
|
24
|
+
"""Import the server lazily, so ``hanakura version`` does not pay for FastAPI."""
|
|
25
|
+
if name in _LAZY:
|
|
26
|
+
from hanakura import embed
|
|
27
|
+
|
|
28
|
+
return getattr(embed, name)
|
|
29
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def __dir__() -> list[str]:
|
|
33
|
+
return sorted(__all__)
|
hanakura/__main__.py
ADDED
hanakura/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""FastAPI wrapper over ``hanakura.core``."""
|
hanakura/api/app.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""``create_app(services)``: the FastAPI app with routers, the socket, and the web UI."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
9
|
+
|
|
10
|
+
from hanakura.api.errors import install_error_handlers
|
|
11
|
+
from hanakura.api.openapi import HanakuraAPI
|
|
12
|
+
from hanakura.api.paths import validate_public_base_url
|
|
13
|
+
from hanakura.api.routers import app_info, auth, downloads, hubs, library, settings, sources
|
|
14
|
+
from hanakura.api.security import SecurityMiddleware
|
|
15
|
+
from hanakura.api.sockets import SocketBridge
|
|
16
|
+
from hanakura.api.static import SPAStaticFiles, web_dist_dir
|
|
17
|
+
from hanakura.core.context import Services
|
|
18
|
+
from hanakura.version import VERSION
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize_prefix(prefix: str | None) -> str:
|
|
24
|
+
"""``/hub/`` and ``hub`` both become ``/hub``; nothing becomes ``""``."""
|
|
25
|
+
cleaned = (prefix or "").strip().strip("/")
|
|
26
|
+
return f"/{cleaned}" if cleaned else ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_app(
|
|
30
|
+
services: Services,
|
|
31
|
+
bound_host: str | None = None,
|
|
32
|
+
bound_port: int | None = None,
|
|
33
|
+
extra_hosts: set[str] | None = None,
|
|
34
|
+
start_downloads: bool = True,
|
|
35
|
+
serve_ui: bool = True,
|
|
36
|
+
api_prefix: str | None = None,
|
|
37
|
+
public_base_url: str | None = None,
|
|
38
|
+
) -> FastAPI:
|
|
39
|
+
"""Build the application.
|
|
40
|
+
|
|
41
|
+
``api_prefix`` moves everything this package serves — the API, the socket and the web UI —
|
|
42
|
+
under one path, so a host application can mount it beside its own routes without a clash.
|
|
43
|
+
The web UI needs no change: it derives its base URL from the URL its own script was loaded
|
|
44
|
+
from, which already carries the prefix.
|
|
45
|
+
|
|
46
|
+
When mounted, the host must enter this app's ``router.lifespan_context`` on its serving
|
|
47
|
+
event loop and close the services afterwards. ``public_base_url`` supplies a trusted
|
|
48
|
+
browser-facing URL for OAuth behind a proxy; it does not add a second route prefix.
|
|
49
|
+
"""
|
|
50
|
+
prefix = normalize_prefix(api_prefix)
|
|
51
|
+
public_base_url = validate_public_base_url(public_base_url)
|
|
52
|
+
socket_bridge = SocketBridge(services.events, lambda: services.settings.settings.server.access_token)
|
|
53
|
+
|
|
54
|
+
@asynccontextmanager
|
|
55
|
+
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|
56
|
+
await socket_bridge.start()
|
|
57
|
+
if start_downloads:
|
|
58
|
+
services.downloads.start()
|
|
59
|
+
try:
|
|
60
|
+
yield
|
|
61
|
+
finally:
|
|
62
|
+
services.downloads.shutdown()
|
|
63
|
+
await socket_bridge.stop()
|
|
64
|
+
|
|
65
|
+
app = HanakuraAPI(title="Hanakura", version=VERSION, lifespan=lifespan, docs_url=f"{prefix}/docs", redoc_url=None, openapi_url=f"{prefix}/openapi.json")
|
|
66
|
+
app.state.services = services
|
|
67
|
+
app.state.bound_port = bound_port
|
|
68
|
+
app.state.api_prefix = prefix
|
|
69
|
+
app.state.public_base_url = public_base_url
|
|
70
|
+
install_error_handlers(app)
|
|
71
|
+
|
|
72
|
+
for module in (app_info, settings, auth, sources, hubs, downloads, library):
|
|
73
|
+
app.include_router(module.router, prefix=f"{prefix}/api")
|
|
74
|
+
app.mount(f"{prefix}/ws", socket_bridge.asgi_app(prefix), name="socket")
|
|
75
|
+
|
|
76
|
+
origins = services.settings.settings.server.allowed_origins
|
|
77
|
+
if origins:
|
|
78
|
+
app.add_middleware(CORSMiddleware, allow_origins=origins, allow_methods=["*"], allow_headers=["*"], allow_credentials=True)
|
|
79
|
+
app.add_middleware(
|
|
80
|
+
SecurityMiddleware,
|
|
81
|
+
bound_host=lambda: bound_host or services.settings.settings.server.host,
|
|
82
|
+
allowed_origins=lambda: services.settings.settings.server.allowed_origins,
|
|
83
|
+
access_token=lambda: services.settings.settings.server.access_token,
|
|
84
|
+
extra_hosts=extra_hosts,
|
|
85
|
+
prefix=prefix,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Registered last, so it only receives paths nothing else matched.
|
|
89
|
+
dist = web_dist_dir()
|
|
90
|
+
if serve_ui and (dist / "index.html").is_file():
|
|
91
|
+
app.mount(f"{prefix}/", SPAStaticFiles(directory=dist, html=True), name="ui")
|
|
92
|
+
elif serve_ui:
|
|
93
|
+
logger.warning("No web UI build found at %s; serving the API only", dist)
|
|
94
|
+
return app
|
hanakura/api/deps.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""FastAPI dependencies. Services come from ``app.state`` rather than a global locator."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, Request
|
|
6
|
+
|
|
7
|
+
from hanakura.core.context import Services
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_services(request: Request) -> Services:
|
|
11
|
+
return request.app.state.services
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ServicesDep = Annotated[Services, Depends(get_services)]
|
hanakura/api/errors.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Map domain exceptions onto one JSON error shape."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, Request
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from hanakura.core.errors import HanakuraError
|
|
10
|
+
from hanakura.core.record import Record
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ErrorResponse(Record):
|
|
14
|
+
code: str
|
|
15
|
+
message: str
|
|
16
|
+
detail: dict[str, Any] = Field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def error_response(status: int, code: str, message: str, detail: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> JSONResponse:
|
|
20
|
+
return JSONResponse(status_code=status, content={"code": code, "message": message, "detail": detail or {}}, headers=headers)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def _domain_error(_request: Request, exc: Exception) -> JSONResponse:
|
|
24
|
+
assert isinstance(exc, HanakuraError)
|
|
25
|
+
headers = None
|
|
26
|
+
retry_after = exc.detail.get("retry_after") if isinstance(exc.detail, dict) else None
|
|
27
|
+
if exc.http_status == 429 and retry_after is not None:
|
|
28
|
+
headers = {"Retry-After": str(int(retry_after))}
|
|
29
|
+
return error_response(exc.http_status, exc.code, exc.message, exc.detail, headers)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def install_error_handlers(app: FastAPI) -> None:
|
|
33
|
+
app.add_exception_handler(HanakuraError, _domain_error)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Declared on routes so the generated client knows the error shape.
|
|
37
|
+
ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {
|
|
38
|
+
400: {"model": ErrorResponse},
|
|
39
|
+
404: {"model": ErrorResponse},
|
|
40
|
+
409: {"model": ErrorResponse},
|
|
41
|
+
}
|
hanakura/api/openapi.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Add the event models to the OpenAPI schema, so the web UI types socket payloads from it."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastapi.openapi.utils import get_openapi
|
|
7
|
+
|
|
8
|
+
from hanakura.core.events.models import EventBase
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HanakuraAPI(FastAPI):
|
|
12
|
+
"""FastAPI with socket event models included in its OpenAPI schema."""
|
|
13
|
+
|
|
14
|
+
def openapi(self) -> dict[str, Any]:
|
|
15
|
+
if self.openapi_schema:
|
|
16
|
+
return self.openapi_schema
|
|
17
|
+
schema = get_openapi(title=self.title, version=self.version, routes=self.routes, description=self.description)
|
|
18
|
+
components = schema.setdefault("components", {}).setdefault("schemas", {})
|
|
19
|
+
events: dict[str, dict[str, str]] = {}
|
|
20
|
+
for event_cls in EventBase.get_events():
|
|
21
|
+
model_schema = event_cls.model_json_schema(ref_template="#/components/schemas/{model}")
|
|
22
|
+
for name, sub in model_schema.pop("$defs", {}).items():
|
|
23
|
+
components.setdefault(name, sub)
|
|
24
|
+
components[event_cls.__name__] = model_schema
|
|
25
|
+
events[event_cls.__event_name__] = {"$ref": f"#/components/schemas/{event_cls.__name__}"}
|
|
26
|
+
components["ServerEvents"] = {"type": "object", "properties": events, "required": sorted(events)}
|
|
27
|
+
schema["components"]["schemas"] = dict(sorted(components.items()))
|
|
28
|
+
self.openapi_schema = schema
|
|
29
|
+
return schema
|
hanakura/api/paths.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Deployment paths shared by mounted apps, authentication and Socket.IO."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urlsplit
|
|
4
|
+
|
|
5
|
+
from starlette import _utils
|
|
6
|
+
from starlette.requests import Request
|
|
7
|
+
from starlette.types import Scope
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def route_path(scope: Scope) -> str:
|
|
11
|
+
"""Older Starlette strips mount prefixes; newer versions leave that to the router."""
|
|
12
|
+
resolve = getattr(_utils, "get_route_path", None)
|
|
13
|
+
return resolve(scope) if resolve is not None else scope.get("path", "")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def public_prefix(request: Request) -> str:
|
|
17
|
+
base = getattr(request.app.state, "public_base_url", None)
|
|
18
|
+
if base:
|
|
19
|
+
return urlsplit(base).path.rstrip("/")
|
|
20
|
+
root = request.scope.get("root_path", "").rstrip("/")
|
|
21
|
+
return root + (getattr(request.app.state, "api_prefix", "") or "")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_public_base_url(value: str | None) -> str | None:
|
|
25
|
+
"""Only the host application may supply this URL; never derive it from forwarded headers."""
|
|
26
|
+
if value is None:
|
|
27
|
+
return None
|
|
28
|
+
url = urlsplit(value)
|
|
29
|
+
if url.scheme not in ("http", "https") or not url.hostname or url.username is not None or url.password is not None or url.query or url.fragment:
|
|
30
|
+
raise ValueError("public_base_url must be an absolute HTTP(S) URL without credentials, query or fragment")
|
|
31
|
+
if any(c.isspace() for c in value) or "\\" in value:
|
|
32
|
+
raise ValueError("public_base_url contains invalid characters")
|
|
33
|
+
return value.rstrip("/")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""One router per resource, under ``/api/v1``."""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""App version, health and vocabulary."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Request
|
|
4
|
+
|
|
5
|
+
from hanakura.api.deps import ServicesDep
|
|
6
|
+
from hanakura.core.detection.models import BASE_MODELS, MODEL_KINDS
|
|
7
|
+
from hanakura.core.library import LibraryService
|
|
8
|
+
from hanakura.core.library.models import TrashLocation
|
|
9
|
+
from hanakura.core.record import Record
|
|
10
|
+
from hanakura.version import VERSION
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/v1/app", tags=["app"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AppVersion(Record):
|
|
16
|
+
version: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Health(Record):
|
|
20
|
+
status: str
|
|
21
|
+
auth_required: bool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LabeledValue(Record):
|
|
25
|
+
value: str
|
|
26
|
+
label: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AppMeta(Record):
|
|
30
|
+
kinds: list[str]
|
|
31
|
+
base_models: list[LabeledValue]
|
|
32
|
+
layouts: dict[str, dict[str, str]]
|
|
33
|
+
roots_locked: bool
|
|
34
|
+
"""True when a host application supplies the model folders; the interface then hides adding,
|
|
35
|
+
editing and removing them."""
|
|
36
|
+
trash: TrashLocation
|
|
37
|
+
api_prefix: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/version", operation_id="get_app_version")
|
|
41
|
+
def get_version() -> AppVersion:
|
|
42
|
+
return AppVersion(version=VERSION)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.get("/health", operation_id="get_health")
|
|
46
|
+
def get_health(services: ServicesDep) -> Health:
|
|
47
|
+
return Health(status="ok", auth_required=bool(services.settings.settings.server.access_token))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.get("/meta", operation_id="get_app_meta")
|
|
51
|
+
def get_meta(request: Request, services: ServicesDep) -> AppMeta:
|
|
52
|
+
return AppMeta(
|
|
53
|
+
kinds=MODEL_KINDS,
|
|
54
|
+
base_models=[LabeledValue(value=k, label=v) for k, v in BASE_MODELS.items()],
|
|
55
|
+
layouts=LibraryService.layouts(),
|
|
56
|
+
roots_locked=services.library.roots_locked,
|
|
57
|
+
trash=services.library.trash_location(),
|
|
58
|
+
api_prefix=getattr(request.app.state, "api_prefix", "") or "",
|
|
59
|
+
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Civitai authentication: connect, return from the provider, status, disconnect.
|
|
2
|
+
|
|
3
|
+
The browser never receives an access or refresh token. Only the callback is reachable without
|
|
4
|
+
the application's access token, because the browser arrives from Civitai with no header to
|
|
5
|
+
carry it; that request is still tied to the transaction and the browser that started it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Request, Response
|
|
12
|
+
from fastapi.responses import RedirectResponse
|
|
13
|
+
|
|
14
|
+
from hanakura.api.deps import ServicesDep
|
|
15
|
+
from hanakura.api.errors import ERROR_RESPONSES
|
|
16
|
+
from hanakura.api.paths import public_prefix
|
|
17
|
+
from hanakura.core.auth.models import AuthStart, CivitaiAuthStatus, MethodRequest, StartRequest
|
|
18
|
+
from hanakura.core.errors import HanakuraError, ValidationError
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
router = APIRouter(prefix="/v1/auth/civitai", tags=["auth"], responses=ERROR_RESPONSES)
|
|
23
|
+
|
|
24
|
+
CALLBACK_PATH = "/api/v1/auth/civitai/callback"
|
|
25
|
+
BINDING_COOKIE = "hanakura_oauth"
|
|
26
|
+
COOKIE_MAX_AGE = 600
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def callback_url(request: Request, services: ServicesDep) -> str:
|
|
30
|
+
"""The callback to use, taken from the configured allowlist or built for the bound address.
|
|
31
|
+
|
|
32
|
+
The address is never taken from the Host header or forwarding headers: a callback has to
|
|
33
|
+
match one registered with Civitai exactly, and an attacker must not be able to choose it.
|
|
34
|
+
"""
|
|
35
|
+
base = getattr(request.app.state, "public_base_url", None)
|
|
36
|
+
allowed = services.settings.settings.auth.civitai.redirect_uris
|
|
37
|
+
if base:
|
|
38
|
+
callback = f"{base}{CALLBACK_PATH}"
|
|
39
|
+
if allowed and callback not in allowed:
|
|
40
|
+
raise ValidationError("public_base_url does not match a registered auth.civitai.redirect_uris callback")
|
|
41
|
+
return callback
|
|
42
|
+
prefix = public_prefix(request)
|
|
43
|
+
path = f"{prefix}{CALLBACK_PATH}"
|
|
44
|
+
if allowed:
|
|
45
|
+
origin = f"{request.url.scheme}://{request.url.netloc}"
|
|
46
|
+
match = next((uri for uri in allowed if (urlsplit(uri).scheme, urlsplit(uri).netloc) == (request.url.scheme, request.url.netloc) and urlsplit(uri).path == path), None)
|
|
47
|
+
if match is None:
|
|
48
|
+
raise ValidationError(
|
|
49
|
+
f"No callback is registered for {origin}. Add the exact URL to auth.civitai.redirect_uris and register it with Civitai, or use a manual API token."
|
|
50
|
+
)
|
|
51
|
+
return match
|
|
52
|
+
host = services.settings.settings.server.host
|
|
53
|
+
port = getattr(request.app.state, "bound_port", None) or services.settings.settings.server.port
|
|
54
|
+
host = "127.0.0.1" if host in ("0.0.0.0", "::", "localhost") else host
|
|
55
|
+
if ":" in host and not host.startswith("["):
|
|
56
|
+
host = f"[{host}]"
|
|
57
|
+
return f"http://{host}:{port}{path}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.post("/start", operation_id="start_civitai_auth")
|
|
61
|
+
def start(request: Request, response: Response, services: ServicesDep, body: StartRequest | None = None) -> AuthStart:
|
|
62
|
+
"""Begin an authorization and return the URL for the user's browser."""
|
|
63
|
+
return_to = (body.return_to if body else None) or None
|
|
64
|
+
if return_to and (urlsplit(return_to).scheme or return_to.startswith("//")):
|
|
65
|
+
raise ValidationError("return_to must be a path inside this application")
|
|
66
|
+
start_result, secret = services.auth.start(callback_url(request, services), return_to)
|
|
67
|
+
response.set_cookie(
|
|
68
|
+
BINDING_COOKIE,
|
|
69
|
+
secret,
|
|
70
|
+
max_age=COOKIE_MAX_AGE,
|
|
71
|
+
httponly=True,
|
|
72
|
+
samesite="lax",
|
|
73
|
+
# Set only over HTTPS: a loopback deployment is plain HTTP and would drop the cookie.
|
|
74
|
+
secure=urlsplit(getattr(request.app.state, "public_base_url", None) or str(request.url)).scheme == "https",
|
|
75
|
+
path=f"{public_prefix(request)}/api/v1/auth/civitai",
|
|
76
|
+
)
|
|
77
|
+
return start_result
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.get("/callback", operation_id="civitai_auth_callback", include_in_schema=False)
|
|
81
|
+
def callback(
|
|
82
|
+
request: Request, services: ServicesDep, code: str | None = None, state: str | None = None, error: str | None = None, error_description: str | None = None
|
|
83
|
+
) -> Response:
|
|
84
|
+
"""Where Civitai sends the browser back. Never shows a token, and keeps none in the URL."""
|
|
85
|
+
binding = request.cookies.get(BINDING_COOKIE)
|
|
86
|
+
target = "/#/settings"
|
|
87
|
+
status = "error"
|
|
88
|
+
if error:
|
|
89
|
+
logger.info("Civitai authorization was refused: %s", error)
|
|
90
|
+
if state:
|
|
91
|
+
services.auth.transactions.discard(state)
|
|
92
|
+
elif not code or not state:
|
|
93
|
+
logger.info("A Civitai callback arrived without a code or state")
|
|
94
|
+
else:
|
|
95
|
+
try:
|
|
96
|
+
target = services.auth.complete(state, code, binding)
|
|
97
|
+
status = "connected"
|
|
98
|
+
except HanakuraError as e:
|
|
99
|
+
logger.info("Completing the Civitai authorization failed: %s", e)
|
|
100
|
+
|
|
101
|
+
prefix = public_prefix(request)
|
|
102
|
+
if prefix and target.startswith("/") and not target.startswith(f"{prefix}/"):
|
|
103
|
+
target = f"{prefix}{target}"
|
|
104
|
+
separator = "&" if "?" in target else "?"
|
|
105
|
+
response = RedirectResponse(f"{target}{separator}civitai={status}", status_code=303)
|
|
106
|
+
response.delete_cookie(BINDING_COOKIE, path=f"{prefix}/api/v1/auth/civitai")
|
|
107
|
+
response.headers["Cache-Control"] = "no-store"
|
|
108
|
+
response.headers["Referrer-Policy"] = "no-referrer"
|
|
109
|
+
return response
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@router.get("/status", operation_id="get_civitai_auth_status")
|
|
113
|
+
def status(services: ServicesDep) -> CivitaiAuthStatus:
|
|
114
|
+
return services.auth.status()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@router.post("/disconnect", operation_id="disconnect_civitai_auth")
|
|
118
|
+
def disconnect(services: ServicesDep) -> CivitaiAuthStatus:
|
|
119
|
+
"""Revoke and forget the OAuth credentials. The manual token is left untouched."""
|
|
120
|
+
return services.auth.disconnect()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.post("/method", operation_id="set_civitai_auth_method")
|
|
124
|
+
def set_method(services: ServicesDep, body: MethodRequest) -> CivitaiAuthStatus:
|
|
125
|
+
"""Choose which credential to use. Nothing else ever changes this."""
|
|
126
|
+
return services.auth.set_method(body.method)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The download queue."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, status
|
|
4
|
+
|
|
5
|
+
from hanakura.api.deps import ServicesDep
|
|
6
|
+
from hanakura.api.errors import ERROR_RESPONSES
|
|
7
|
+
from hanakura.core.downloads.models import DownloadCreate, DownloadJob
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/v1/downloads", tags=["downloads"], responses=ERROR_RESPONSES)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("", operation_id="list_downloads")
|
|
13
|
+
def list_downloads(services: ServicesDep) -> list[DownloadJob]:
|
|
14
|
+
return services.downloads.list_jobs()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.post("", operation_id="create_download", status_code=status.HTTP_201_CREATED)
|
|
18
|
+
def create_download(services: ServicesDep, body: DownloadCreate) -> DownloadJob:
|
|
19
|
+
return services.downloads.create(body)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post("/clear-finished", operation_id="clear_finished_downloads")
|
|
23
|
+
def clear_finished(services: ServicesDep) -> list[int]:
|
|
24
|
+
return services.downloads.clear_finished()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/{job_id}", operation_id="get_download")
|
|
28
|
+
def get_download(services: ServicesDep, job_id: int) -> DownloadJob:
|
|
29
|
+
return services.downloads.get(job_id)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/{job_id}/pause", operation_id="pause_download")
|
|
33
|
+
def pause_download(services: ServicesDep, job_id: int) -> DownloadJob:
|
|
34
|
+
return services.downloads.pause(job_id)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.post("/{job_id}/resume", operation_id="resume_download")
|
|
38
|
+
def resume_download(services: ServicesDep, job_id: int) -> DownloadJob:
|
|
39
|
+
return services.downloads.resume(job_id)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post("/{job_id}/cancel", operation_id="cancel_download")
|
|
43
|
+
def cancel_download(services: ServicesDep, job_id: int) -> DownloadJob:
|
|
44
|
+
return services.downloads.cancel(job_id)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.post("/{job_id}/restart", operation_id="restart_download")
|
|
48
|
+
def restart_download(services: ServicesDep, job_id: int) -> DownloadJob:
|
|
49
|
+
return services.downloads.restart(job_id)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.delete("/{job_id}", operation_id="delete_download", status_code=status.HTTP_204_NO_CONTENT)
|
|
53
|
+
def delete_download(services: ServicesDep, job_id: int) -> None:
|
|
54
|
+
services.downloads.remove(job_id)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Hugging Face and ModelScope."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Query
|
|
4
|
+
|
|
5
|
+
from hanakura.api.deps import ServicesDep
|
|
6
|
+
from hanakura.api.errors import ERROR_RESPONSES
|
|
7
|
+
from hanakura.core.hubs.models import HubInfo, HubPage, HubQuery, RepoDetail, RepoFile
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/v1/hubs", tags=["hubs"], responses=ERROR_RESPONSES)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("", operation_id="list_hubs")
|
|
13
|
+
def list_hubs(services: ServicesDep) -> list[HubInfo]:
|
|
14
|
+
return services.hubs.list_hubs()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/{hub}/repos", operation_id="search_repos")
|
|
18
|
+
def search_repos(
|
|
19
|
+
services: ServicesDep,
|
|
20
|
+
hub: str,
|
|
21
|
+
query: str = "",
|
|
22
|
+
sort: str | None = None,
|
|
23
|
+
limit: int = Query(default=30, ge=1, le=100),
|
|
24
|
+
cursor: str | None = None,
|
|
25
|
+
) -> HubPage:
|
|
26
|
+
return services.hubs.search(hub, HubQuery(query=query, sort=sort, limit=limit, cursor=cursor))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Repository ids contain a slash, so they are path parameters of type path.
|
|
30
|
+
@router.get("/{hub}/repos/{repo_id:path}/files", operation_id="list_repo_files")
|
|
31
|
+
def list_repo_files(services: ServicesDep, hub: str, repo_id: str, revision: str | None = None) -> list[RepoFile]:
|
|
32
|
+
return services.hubs.list_files(hub, repo_id, revision)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.get("/{hub}/repos/{repo_id:path}", operation_id="get_repo")
|
|
36
|
+
def get_repo(services: ServicesDep, hub: str, repo_id: str, revision: str | None = None) -> RepoDetail:
|
|
37
|
+
return services.hubs.get_repo(hub, repo_id, revision)
|