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.
Files changed (135) hide show
  1. hanakura/__init__.py +33 -0
  2. hanakura/__main__.py +6 -0
  3. hanakura/api/__init__.py +1 -0
  4. hanakura/api/app.py +94 -0
  5. hanakura/api/deps.py +14 -0
  6. hanakura/api/errors.py +41 -0
  7. hanakura/api/openapi.py +29 -0
  8. hanakura/api/paths.py +33 -0
  9. hanakura/api/routers/__init__.py +1 -0
  10. hanakura/api/routers/app_info.py +59 -0
  11. hanakura/api/routers/auth.py +126 -0
  12. hanakura/api/routers/downloads.py +54 -0
  13. hanakura/api/routers/hubs.py +37 -0
  14. hanakura/api/routers/library.py +176 -0
  15. hanakura/api/routers/settings.py +45 -0
  16. hanakura/api/routers/sources.py +53 -0
  17. hanakura/api/security.py +125 -0
  18. hanakura/api/sockets.py +81 -0
  19. hanakura/api/static.py +49 -0
  20. hanakura/cli/__init__.py +1 -0
  21. hanakura/cli/app.py +110 -0
  22. hanakura/cli/commands/__init__.py +1 -0
  23. hanakura/cli/commands/config.py +47 -0
  24. hanakura/cli/commands/download.py +164 -0
  25. hanakura/cli/commands/library.py +255 -0
  26. hanakura/cli/commands/search.py +142 -0
  27. hanakura/cli/commands/system.py +48 -0
  28. hanakura/cli/commands/webui.py +75 -0
  29. hanakura/cli/factory.py +91 -0
  30. hanakura/cli/output.py +69 -0
  31. hanakura/core/__init__.py +1 -0
  32. hanakura/core/auth/__init__.py +7 -0
  33. hanakura/core/auth/models.py +54 -0
  34. hanakura/core/auth/oauth_client.py +156 -0
  35. hanakura/core/auth/service.py +282 -0
  36. hanakura/core/auth/store.py +220 -0
  37. hanakura/core/auth/transactions.py +89 -0
  38. hanakura/core/context.py +67 -0
  39. hanakura/core/db/__init__.py +5 -0
  40. hanakura/core/db/database.py +114 -0
  41. hanakura/core/detection/__init__.py +6 -0
  42. hanakura/core/detection/header.py +150 -0
  43. hanakura/core/detection/kinds.py +155 -0
  44. hanakura/core/detection/models.py +142 -0
  45. hanakura/core/detection/rules.py +121 -0
  46. hanakura/core/detection/rules_data/diffusion.json +162 -0
  47. hanakura/core/detection/rules_data/lora.json +82 -0
  48. hanakura/core/detection/rules_data/other.json +116 -0
  49. hanakura/core/detection/service.py +241 -0
  50. hanakura/core/downloads/__init__.py +1 -0
  51. hanakura/core/downloads/http_downloader.py +184 -0
  52. hanakura/core/downloads/hub_runner.py +166 -0
  53. hanakura/core/downloads/job.py +44 -0
  54. hanakura/core/downloads/manager.py +612 -0
  55. hanakura/core/downloads/models.py +84 -0
  56. hanakura/core/errors.py +75 -0
  57. hanakura/core/events/__init__.py +6 -0
  58. hanakura/core/events/bus.py +53 -0
  59. hanakura/core/events/models.py +78 -0
  60. hanakura/core/hubs/__init__.py +6 -0
  61. hanakura/core/hubs/base.py +82 -0
  62. hanakura/core/hubs/huggingface.py +89 -0
  63. hanakura/core/hubs/models.py +61 -0
  64. hanakura/core/hubs/modelscope.py +86 -0
  65. hanakura/core/hubs/registry.py +80 -0
  66. hanakura/core/hubs/worker.py +94 -0
  67. hanakura/core/library/__init__.py +5 -0
  68. hanakura/core/library/fsops.py +78 -0
  69. hanakura/core/library/layouts.py +129 -0
  70. hanakura/core/library/models.py +169 -0
  71. hanakura/core/library/previews.py +127 -0
  72. hanakura/core/library/safety.py +101 -0
  73. hanakura/core/library/service.py +765 -0
  74. hanakura/core/library/sidecar.py +111 -0
  75. hanakura/core/library/thumbnails.py +36 -0
  76. hanakura/core/net/__init__.py +1 -0
  77. hanakura/core/net/http.py +46 -0
  78. hanakura/core/net/ports.py +66 -0
  79. hanakura/core/net/runtime_file.py +51 -0
  80. hanakura/core/paths.py +22 -0
  81. hanakura/core/record.py +93 -0
  82. hanakura/core/settings/__init__.py +6 -0
  83. hanakura/core/settings/models.py +169 -0
  84. hanakura/core/settings/service.py +248 -0
  85. hanakura/core/sources/__init__.py +6 -0
  86. hanakura/core/sources/base.py +146 -0
  87. hanakura/core/sources/civitai.py +221 -0
  88. hanakura/core/sources/github_releases.py +114 -0
  89. hanakura/core/sources/models.py +126 -0
  90. hanakura/core/sources/openmodeldb.py +158 -0
  91. hanakura/core/sources/registry.py +150 -0
  92. hanakura/embed.py +315 -0
  93. hanakura/logger.py +24 -0
  94. hanakura/version.py +3 -0
  95. hanakura/webui/__init__.py +1 -0
  96. hanakura/webui/dist/assets/BrowseView-2dYJlaiI.css +1 -0
  97. hanakura/webui/dist/assets/BrowseView-BXdex7e5.js +1 -0
  98. hanakura/webui/dist/assets/DestinationPicker-BDMXAB5C.js +1 -0
  99. hanakura/webui/dist/assets/DestinationPicker-CbLw5jhe.css +1 -0
  100. hanakura/webui/dist/assets/DirectView-CXj1wivq.css +1 -0
  101. hanakura/webui/dist/assets/DirectView-DZHTCSX9.js +1 -0
  102. hanakura/webui/dist/assets/HubsView-BmbdFXQT.css +1 -0
  103. hanakura/webui/dist/assets/HubsView-ChcfqHE_.js +1 -0
  104. hanakura/webui/dist/assets/LibraryView-CnFSoQk0.js +2 -0
  105. hanakura/webui/dist/assets/LibraryView-D3YMPzUu.css +1 -0
  106. hanakura/webui/dist/assets/MarkdownContent-D-GEOQod.js +15 -0
  107. hanakura/webui/dist/assets/MarkdownContent-amdhRUML.css +1 -0
  108. hanakura/webui/dist/assets/ModelCard-BhnNNuMX.js +1 -0
  109. hanakura/webui/dist/assets/ModelCard-CI50BQS7.css +1 -0
  110. hanakura/webui/dist/assets/ModelGrid-D7_Fff78.css +1 -0
  111. hanakura/webui/dist/assets/ModelGrid-D_9yZqhT.js +1 -0
  112. hanakura/webui/dist/assets/SettingsView-8mrUyfmo.css +1 -0
  113. hanakura/webui/dist/assets/SettingsView-BsHugFUH.js +1 -0
  114. hanakura/webui/dist/assets/hubs-DvN83F-C.js +1 -0
  115. hanakura/webui/dist/assets/index-Bx38Yo7R.css +1 -0
  116. hanakura/webui/dist/assets/index-CKmtUUAb.js +2 -0
  117. hanakura/webui/dist/assets/library-BFPgbNr_.js +649 -0
  118. hanakura/webui/dist/assets/library-DHeT47RV.css +1 -0
  119. hanakura/webui/dist/assets/roboto-flex-cyrillic-ext-wght-normal-CVcCQn0y.woff2 +0 -0
  120. hanakura/webui/dist/assets/roboto-flex-cyrillic-wght-normal-BJ-nQEdz.woff2 +0 -0
  121. hanakura/webui/dist/assets/roboto-flex-greek-wght-normal-DeN4rzfy.woff2 +0 -0
  122. hanakura/webui/dist/assets/roboto-flex-latin-ext-wght-normal-gysdOMcS.woff2 +0 -0
  123. hanakura/webui/dist/assets/roboto-flex-latin-wght-normal-n-dP9qr2.woff2 +0 -0
  124. hanakura/webui/dist/assets/roboto-flex-vietnamese-wght-normal-AVFfiTln.woff2 +0 -0
  125. hanakura/webui/dist/assets/rolldown-runtime-DK3Fl9T5.js +1 -0
  126. hanakura/webui/dist/assets/sources-xkzDGvQZ.js +1 -0
  127. hanakura/webui/dist/assets/useInfiniteQuery-OhRo3zPv.js +1 -0
  128. hanakura/webui/dist/favicon.svg +1 -0
  129. hanakura/webui/dist/index.html +31 -0
  130. hanakura-0.1.0.dist-info/METADATA +383 -0
  131. hanakura-0.1.0.dist-info/RECORD +135 -0
  132. hanakura-0.1.0.dist-info/WHEEL +5 -0
  133. hanakura-0.1.0.dist-info/entry_points.txt +2 -0
  134. hanakura-0.1.0.dist-info/licenses/LICENSE +674 -0
  135. 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
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m hanakura``."""
2
+
3
+ from hanakura.cli.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ }
@@ -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)