mirror of
https://github.com/langgenius/dify.git
synced 2026-05-12 15:58:19 +08:00
Conflicts resolved: - api/services/app_service.py: extend AppListParams with status + openapi_visible fields so the openapi caller's per-page visibility gate survives the dict->BaseModel refactor; openapi controller now constructs AppListParams. - pnpm-workspace.yaml: union of CLI-only entries (@napi-rs/keyring, @oclif/*) with main's bumped versions (@next/*, @orpc/*, eslint-plugin-sonarjs, eslint-plugin-storybook); kept eventsource-parser. - pnpm-lock.yaml: regenerated. - web/app/signin/utils/post-login-redirect.ts: union impl — keep main's resolvePostLoginRedirect(searchParams) + setOAuthPendingRedirect; add hardened sessionStorage-based setPostLoginRedirect for device flow with same-origin + path whitelist; device redirect takes precedence over oauth pending.
330 lines
11 KiB
Python
330 lines
11 KiB
Python
"""GET /openapi/v1/apps and per-app reads.
|
|
|
|
Decorator order: `method_decorators` is innermost-first. `validate_bearer`
|
|
is last → outermost → sets `g.auth_ctx` before `require_scope` reads it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid as _uuid
|
|
from typing import Any
|
|
|
|
import sqlalchemy as sa
|
|
from flask import g, request
|
|
from flask_restx import Resource
|
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
|
from werkzeug.exceptions import Conflict, NotFound, UnprocessableEntity
|
|
|
|
from controllers.common.fields import Parameters
|
|
from controllers.openapi import openapi_ns
|
|
from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA, build_input_schema, resolve_app_config
|
|
from controllers.openapi._models import (
|
|
MAX_PAGE_LIMIT,
|
|
AppDescribeInfo,
|
|
AppDescribeResponse,
|
|
AppListRow,
|
|
PaginationEnvelope,
|
|
)
|
|
from controllers.openapi.auth.surface_gate import accept_subjects
|
|
from controllers.service_api.app.error import AppUnavailableError
|
|
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
|
from extensions.ext_database import db
|
|
from libs.oauth_bearer import (
|
|
ACCEPT_USER_ANY,
|
|
AuthContext,
|
|
Scope,
|
|
SubjectType,
|
|
require_scope,
|
|
require_workspace_member,
|
|
validate_bearer,
|
|
)
|
|
from models import App, Tenant
|
|
from models.model import AppMode
|
|
from services.app_service import AppListParams, AppService
|
|
from services.openapi.visibility import apply_openapi_gate, is_openapi_visible
|
|
from services.tag_service import TagService
|
|
|
|
# method_decorators applies left-to-right innermost-first; flask_restx wraps
|
|
# in order, so the LAST entry is the outermost. Execution flows
|
|
# validate_bearer → accept_subjects → require_scope → handler.
|
|
_APPS_READ_DECORATORS = [
|
|
require_scope(Scope.APPS_READ),
|
|
accept_subjects(SubjectType.ACCOUNT),
|
|
validate_bearer(accept=ACCEPT_USER_ANY),
|
|
]
|
|
|
|
_ALLOWED_DESCRIBE_FIELDS: frozenset[str] = frozenset({"info", "parameters", "input_schema"})
|
|
|
|
|
|
class AppDescribeQuery(BaseModel):
|
|
"""`?fields=` allow-list for GET /apps/<id>/describe.
|
|
|
|
Empty / omitted → all blocks. Unknown member → ValidationError → 422.
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
fields: set[str] | None = None
|
|
workspace_id: str | None = None
|
|
|
|
@field_validator("workspace_id", mode="before")
|
|
@classmethod
|
|
def _validate_workspace_id(cls, v: object) -> str | None:
|
|
if v is None or v == "":
|
|
return None
|
|
if not isinstance(v, str):
|
|
raise ValueError("workspace_id must be a string")
|
|
try:
|
|
_uuid.UUID(v)
|
|
except ValueError:
|
|
raise ValueError("workspace_id must be a valid UUID")
|
|
return v
|
|
|
|
@field_validator("fields", mode="before")
|
|
@classmethod
|
|
def _parse_fields(cls, v: object) -> set[str] | None:
|
|
if v is None or v == "":
|
|
return None
|
|
if not isinstance(v, str):
|
|
raise ValueError("fields must be a comma-separated string")
|
|
members = {m.strip() for m in v.split(",") if m.strip()}
|
|
unknown = members - _ALLOWED_DESCRIBE_FIELDS
|
|
if unknown:
|
|
raise ValueError(f"unknown field(s): {sorted(unknown)}")
|
|
return members
|
|
|
|
|
|
_EMPTY_PARAMETERS: dict[str, Any] = {
|
|
"opening_statement": None,
|
|
"suggested_questions": [],
|
|
"user_input_form": [],
|
|
"file_upload": None,
|
|
"system_parameters": {},
|
|
}
|
|
|
|
|
|
class AppReadResource(Resource):
|
|
"""Base for per-app read endpoints; subclasses call `_load()` for SSO/membership/exists checks."""
|
|
|
|
method_decorators = _APPS_READ_DECORATORS
|
|
|
|
def _load(self, app_id: str, workspace_id: str | None = None) -> tuple[App, AuthContext]:
|
|
ctx: AuthContext = g.auth_ctx
|
|
|
|
try:
|
|
parsed_uuid = _uuid.UUID(app_id)
|
|
is_uuid = True
|
|
except ValueError:
|
|
parsed_uuid = None
|
|
is_uuid = False
|
|
|
|
if is_uuid:
|
|
app = db.session.get(App, str(parsed_uuid)) # normalised dashed form
|
|
if not app or app.status != "normal" or not is_openapi_visible(app):
|
|
raise NotFound("app not found")
|
|
else:
|
|
if not workspace_id:
|
|
raise UnprocessableEntity("workspace_id is required for name-based lookup")
|
|
matches = list(
|
|
db.session.execute(
|
|
apply_openapi_gate(
|
|
sa.select(App).where(
|
|
App.name == app_id,
|
|
App.tenant_id == workspace_id,
|
|
App.status == "normal",
|
|
)
|
|
)
|
|
).scalars()
|
|
)
|
|
if len(matches) == 0:
|
|
raise NotFound("app not found")
|
|
if len(matches) > 1:
|
|
lines = [f"app name {app_id!r} is ambiguous — re-run with a UUID:\n\n"]
|
|
lines.append(f" {'ID':<36} {'MODE':<12} NAME\n")
|
|
for m in matches:
|
|
lines.append(f" {str(m.id):<36} {str(m.mode.value):<12} {m.name}\n")
|
|
raise Conflict("".join(lines))
|
|
app = matches[0]
|
|
|
|
require_workspace_member(ctx, str(app.tenant_id))
|
|
return app, ctx
|
|
|
|
|
|
def parameters_payload(app: App) -> dict:
|
|
"""Mirrors service_api/app/app.py::AppParameterApi response body."""
|
|
features_dict, user_input_form = resolve_app_config(app)
|
|
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
|
|
return Parameters.model_validate(parameters).model_dump(mode="json")
|
|
|
|
|
|
@openapi_ns.route("/apps/<string:app_id>/describe")
|
|
class AppDescribeApi(AppReadResource):
|
|
def get(self, app_id: str):
|
|
try:
|
|
query = AppDescribeQuery.model_validate(request.args.to_dict(flat=True))
|
|
except ValidationError as exc:
|
|
raise UnprocessableEntity(exc.json())
|
|
|
|
app, _ = self._load(app_id, workspace_id=query.workspace_id)
|
|
|
|
requested = query.fields
|
|
want_info = requested is None or "info" in requested
|
|
want_params = requested is None or "parameters" in requested
|
|
want_schema = requested is None or "input_schema" in requested
|
|
|
|
info = (
|
|
AppDescribeInfo(
|
|
id=str(app.id),
|
|
name=app.name,
|
|
mode=app.mode,
|
|
description=app.description,
|
|
tags=[{"name": t.name} for t in app.tags],
|
|
author=app.author_name,
|
|
updated_at=app.updated_at.isoformat() if app.updated_at else None,
|
|
service_api_enabled=bool(app.enable_api),
|
|
)
|
|
if want_info
|
|
else None
|
|
)
|
|
|
|
parameters: dict[str, Any] | None = None
|
|
input_schema: dict[str, Any] | None = None
|
|
if want_params:
|
|
try:
|
|
parameters = parameters_payload(app)
|
|
except AppUnavailableError:
|
|
parameters = dict(_EMPTY_PARAMETERS)
|
|
if want_schema:
|
|
try:
|
|
input_schema = build_input_schema(app)
|
|
except AppUnavailableError:
|
|
input_schema = dict(EMPTY_INPUT_SCHEMA)
|
|
|
|
return (
|
|
AppDescribeResponse(
|
|
info=info,
|
|
parameters=parameters,
|
|
input_schema=input_schema,
|
|
).model_dump(mode="json", exclude_none=False),
|
|
200,
|
|
)
|
|
|
|
|
|
class AppListQuery(BaseModel):
|
|
"""`mode` is a closed enum — unknown values 422 instead of silently-empty data."""
|
|
|
|
workspace_id: str
|
|
page: int = Field(1, ge=1)
|
|
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
|
|
mode: AppMode | None = None
|
|
name: str | None = Field(None, max_length=200)
|
|
tag: str | None = Field(None, max_length=100)
|
|
|
|
|
|
@openapi_ns.route("/apps")
|
|
class AppListApi(Resource):
|
|
method_decorators = _APPS_READ_DECORATORS
|
|
|
|
def get(self):
|
|
ctx: AuthContext = g.auth_ctx
|
|
|
|
try:
|
|
query = AppListQuery.model_validate(request.args.to_dict(flat=True))
|
|
except ValidationError as exc:
|
|
raise UnprocessableEntity(exc.json())
|
|
|
|
workspace_id = query.workspace_id
|
|
require_workspace_member(ctx, workspace_id)
|
|
|
|
empty = (
|
|
PaginationEnvelope[AppListRow]
|
|
.build(page=query.page, limit=query.limit, total=0, items=[])
|
|
.model_dump(mode="json"),
|
|
200,
|
|
)
|
|
|
|
if query.name:
|
|
try:
|
|
parsed_uuid = _uuid.UUID(query.name)
|
|
except ValueError:
|
|
parsed_uuid = None
|
|
else:
|
|
parsed_uuid = None
|
|
|
|
if parsed_uuid is not None:
|
|
app = db.session.get(App, str(parsed_uuid))
|
|
if (
|
|
not app
|
|
or app.status != "normal"
|
|
or str(app.tenant_id) != workspace_id
|
|
or not is_openapi_visible(app)
|
|
):
|
|
return empty
|
|
tenant_name = db.session.execute(
|
|
sa.select(Tenant.name).where(Tenant.id == workspace_id)
|
|
).scalar_one_or_none()
|
|
item = AppListRow(
|
|
id=str(app.id),
|
|
name=app.name,
|
|
description=app.description,
|
|
mode=app.mode,
|
|
tags=[{"name": t.name} for t in app.tags],
|
|
updated_at=app.updated_at.isoformat() if app.updated_at else None,
|
|
created_by_name=getattr(app, "author_name", None),
|
|
workspace_id=str(workspace_id),
|
|
workspace_name=tenant_name,
|
|
)
|
|
env = PaginationEnvelope[AppListRow].build(page=1, limit=1, total=1, items=[item])
|
|
return env.model_dump(mode="json"), 200
|
|
|
|
tag_ids: list[str] | None = None
|
|
if query.tag:
|
|
tags = TagService.get_tag_by_tag_name("app", workspace_id, query.tag)
|
|
if not tags:
|
|
return empty
|
|
tag_ids = [tag.id for tag in tags]
|
|
|
|
params = AppListParams(
|
|
page=query.page,
|
|
limit=query.limit,
|
|
mode=query.mode.value if query.mode else "all",
|
|
name=query.name,
|
|
tag_ids=tag_ids,
|
|
status="normal",
|
|
# Visibility gate pushed into the query — pagination.total stays
|
|
# consistent across pages because invisible rows never count.
|
|
openapi_visible=True,
|
|
)
|
|
|
|
pagination = AppService().get_paginate_apps(ctx.account_id, workspace_id, params)
|
|
if pagination is None:
|
|
return empty
|
|
|
|
tenant_name: str | None = None
|
|
if pagination.items:
|
|
tenant_name = db.session.execute(
|
|
sa.select(Tenant.name).where(Tenant.id == workspace_id)
|
|
).scalar_one_or_none()
|
|
|
|
items = [
|
|
AppListRow(
|
|
id=str(r.id),
|
|
name=r.name,
|
|
description=r.description,
|
|
mode=r.mode,
|
|
tags=[{"name": t.name} for t in r.tags],
|
|
updated_at=r.updated_at.isoformat() if r.updated_at else None,
|
|
created_by_name=getattr(r, "author_name", None),
|
|
workspace_id=str(workspace_id),
|
|
workspace_name=tenant_name,
|
|
)
|
|
for r in pagination.items
|
|
]
|
|
env = PaginationEnvelope[AppListRow].build(
|
|
page=query.page,
|
|
limit=query.limit,
|
|
total=int(pagination.total),
|
|
items=items,
|
|
)
|
|
return env.model_dump(mode="json"), 200
|