diff --git a/api/controllers/web/__init__.py b/api/controllers/web/__init__.py index 56749a0e2..3b0a9e341 100644 --- a/api/controllers/web/__init__.py +++ b/api/controllers/web/__init__.py @@ -1,19 +1,20 @@ from flask import Blueprint +from flask_restx import Namespace from libs.external_api import ExternalApi -from .files import FileApi -from .remote_files import RemoteFileInfoApi, RemoteFileUploadApi - bp = Blueprint("web", __name__, url_prefix="/api") -api = ExternalApi(bp) -# Files -api.add_resource(FileApi, "/files/upload") +api = ExternalApi( + bp, + version="1.0", + title="Web API", + description="Public APIs for web applications including file uploads, chat interactions, and app management", + doc="/docs", # Enable Swagger UI at /api/docs +) -# Remote files -api.add_resource(RemoteFileInfoApi, "/remote-files/") -api.add_resource(RemoteFileUploadApi, "/remote-files/upload") +# Create namespace +web_ns = Namespace("web", description="Web application API operations", path="/") from . import ( app, @@ -21,11 +22,15 @@ from . import ( completion, conversation, feature, + files, forgot_password, login, message, passport, + remote_files, saved_message, site, workflow, ) + +api.add_namespace(web_ns) diff --git a/api/controllers/web/feature.py b/api/controllers/web/feature.py index 478b3d2e3..cce3dae95 100644 --- a/api/controllers/web/feature.py +++ b/api/controllers/web/feature.py @@ -1,12 +1,21 @@ from flask_restx import Resource -from controllers.web import api +from controllers.web import web_ns from services.feature_service import FeatureService +@web_ns.route("/system-features") class SystemFeatureApi(Resource): + @web_ns.doc("get_system_features") + @web_ns.doc(description="Get system feature flags and configuration") + @web_ns.doc(responses={200: "System features retrieved successfully", 500: "Internal server error"}) def get(self): + """Get system feature flags and configuration. + + Returns the current system feature flags and configuration + that control various functionalities across the platform. + + Returns: + dict: System feature configuration object + """ return FeatureService.get_system_features().model_dump() - - -api.add_resource(SystemFeatureApi, "/system-features") diff --git a/api/controllers/web/files.py b/api/controllers/web/files.py index b05e2a2e6..7508874fa 100644 --- a/api/controllers/web/files.py +++ b/api/controllers/web/files.py @@ -9,14 +9,50 @@ from controllers.common.errors import ( TooManyFilesError, UnsupportedFileTypeError, ) +from controllers.web import web_ns from controllers.web.wraps import WebApiResource -from fields.file_fields import file_fields +from fields.file_fields import build_file_model from services.file_service import FileService +@web_ns.route("/files/upload") class FileApi(WebApiResource): - @marshal_with(file_fields) + @web_ns.doc("upload_file") + @web_ns.doc(description="Upload a file for use in web applications") + @web_ns.doc( + responses={ + 201: "File uploaded successfully", + 400: "Bad request - invalid file or parameters", + 413: "File too large", + 415: "Unsupported file type", + } + ) + @marshal_with(build_file_model(web_ns)) def post(self, app_model, end_user): + """Upload a file for use in web applications. + + Accepts file uploads for use within web applications, supporting + multiple file types with automatic validation and storage. + + Args: + app_model: The associated application model + end_user: The end user uploading the file + + Form Parameters: + file: The file to upload (required) + source: Optional source type (datasets or None) + + Returns: + dict: File information including ID, URL, and metadata + int: HTTP status code 201 for success + + Raises: + NoFileUploadedError: No file provided in request + TooManyFilesError: Multiple files provided (only one allowed) + FilenameNotExistsError: File has no filename + FileTooLargeError: File exceeds size limit + UnsupportedFileTypeError: File type not supported + """ if "file" not in request.files: raise NoFileUploadedError() diff --git a/api/controllers/web/forgot_password.py b/api/controllers/web/forgot_password.py index b596d969a..c743d0f52 100644 --- a/api/controllers/web/forgot_password.py +++ b/api/controllers/web/forgot_password.py @@ -16,7 +16,7 @@ from controllers.console.auth.error import ( ) from controllers.console.error import EmailSendIpLimitError from controllers.console.wraps import email_password_login_enabled, only_edition_enterprise, setup_required -from controllers.web import api +from controllers.web import web_ns from extensions.ext_database import db from libs.helper import email, extract_remote_ip from libs.password import hash_password, valid_password @@ -24,10 +24,21 @@ from models.account import Account from services.account_service import AccountService +@web_ns.route("/forgot-password") class ForgotPasswordSendEmailApi(Resource): @only_edition_enterprise @setup_required @email_password_login_enabled + @web_ns.doc("send_forgot_password_email") + @web_ns.doc(description="Send password reset email") + @web_ns.doc( + responses={ + 200: "Password reset email sent successfully", + 400: "Bad request - invalid email format", + 404: "Account not found", + 429: "Too many requests - rate limit exceeded", + } + ) def post(self): parser = reqparse.RequestParser() parser.add_argument("email", type=email, required=True, location="json") @@ -54,10 +65,16 @@ class ForgotPasswordSendEmailApi(Resource): return {"result": "success", "data": token} +@web_ns.route("/forgot-password/validity") class ForgotPasswordCheckApi(Resource): @only_edition_enterprise @setup_required @email_password_login_enabled + @web_ns.doc("check_forgot_password_token") + @web_ns.doc(description="Verify password reset token validity") + @web_ns.doc( + responses={200: "Token is valid", 400: "Bad request - invalid token format", 401: "Invalid or expired token"} + ) def post(self): parser = reqparse.RequestParser() parser.add_argument("email", type=str, required=True, location="json") @@ -94,10 +111,21 @@ class ForgotPasswordCheckApi(Resource): return {"is_valid": True, "email": token_data.get("email"), "token": new_token} +@web_ns.route("/forgot-password/resets") class ForgotPasswordResetApi(Resource): @only_edition_enterprise @setup_required @email_password_login_enabled + @web_ns.doc("reset_password") + @web_ns.doc(description="Reset user password with verification token") + @web_ns.doc( + responses={ + 200: "Password reset successfully", + 400: "Bad request - invalid parameters or password mismatch", + 401: "Invalid or expired token", + 404: "Account not found", + } + ) def post(self): parser = reqparse.RequestParser() parser.add_argument("token", type=str, required=True, nullable=False, location="json") @@ -141,8 +169,3 @@ class ForgotPasswordResetApi(Resource): account.password = base64.b64encode(password_hashed).decode() account.password_salt = base64.b64encode(salt).decode() session.commit() - - -api.add_resource(ForgotPasswordSendEmailApi, "/forgot-password") -api.add_resource(ForgotPasswordCheckApi, "/forgot-password/validity") -api.add_resource(ForgotPasswordResetApi, "/forgot-password/resets") diff --git a/api/controllers/web/login.py b/api/controllers/web/login.py index 4f6ff5c1a..d2b7c72ba 100644 --- a/api/controllers/web/login.py +++ b/api/controllers/web/login.py @@ -9,18 +9,30 @@ from controllers.console.auth.error import ( ) from controllers.console.error import AccountBannedError from controllers.console.wraps import only_edition_enterprise, setup_required -from controllers.web import api +from controllers.web import web_ns from libs.helper import email from libs.password import valid_password from services.account_service import AccountService from services.webapp_auth_service import WebAppAuthService +@web_ns.route("/login") class LoginApi(Resource): """Resource for web app email/password login.""" @setup_required @only_edition_enterprise + @web_ns.doc("web_app_login") + @web_ns.doc(description="Authenticate user for web application access") + @web_ns.doc( + responses={ + 200: "Authentication successful", + 400: "Bad request - invalid email or password format", + 401: "Authentication failed - email or password mismatch", + 403: "Account banned or login disabled", + 404: "Account not found", + } + ) def post(self): """Authenticate user and login.""" parser = reqparse.RequestParser() @@ -51,9 +63,19 @@ class LoginApi(Resource): # return {"result": "success"} +@web_ns.route("/email-code-login") class EmailCodeLoginSendEmailApi(Resource): @setup_required @only_edition_enterprise + @web_ns.doc("send_email_code_login") + @web_ns.doc(description="Send email verification code for login") + @web_ns.doc( + responses={ + 200: "Email code sent successfully", + 400: "Bad request - invalid email format", + 404: "Account not found", + } + ) def post(self): parser = reqparse.RequestParser() parser.add_argument("email", type=email, required=True, location="json") @@ -74,9 +96,20 @@ class EmailCodeLoginSendEmailApi(Resource): return {"result": "success", "data": token} +@web_ns.route("/email-code-login/validity") class EmailCodeLoginApi(Resource): @setup_required @only_edition_enterprise + @web_ns.doc("verify_email_code_login") + @web_ns.doc(description="Verify email code and complete login") + @web_ns.doc( + responses={ + 200: "Email code verified and login successful", + 400: "Bad request - invalid code or token", + 401: "Invalid token or expired code", + 404: "Account not found", + } + ) def post(self): parser = reqparse.RequestParser() parser.add_argument("email", type=str, required=True, location="json") @@ -104,9 +137,3 @@ class EmailCodeLoginApi(Resource): token = WebAppAuthService.login(account=account) AccountService.reset_login_error_rate_limit(args["email"]) return {"result": "success", "data": {"access_token": token}} - - -api.add_resource(LoginApi, "/login") -# api.add_resource(LogoutApi, "/logout") -api.add_resource(EmailCodeLoginSendEmailApi, "/email-code-login") -api.add_resource(EmailCodeLoginApi, "/email-code-login/validity") diff --git a/api/controllers/web/passport.py b/api/controllers/web/passport.py index 1ac20e653..6f7105a72 100644 --- a/api/controllers/web/passport.py +++ b/api/controllers/web/passport.py @@ -7,7 +7,7 @@ from sqlalchemy import func, select from werkzeug.exceptions import NotFound, Unauthorized from configs import dify_config -from controllers.web import api +from controllers.web import web_ns from controllers.web.error import WebAppAuthRequiredError from extensions.ext_database import db from libs.passport import PassportService @@ -17,9 +17,19 @@ from services.feature_service import FeatureService from services.webapp_auth_service import WebAppAuthService, WebAppAuthType +@web_ns.route("/passport") class PassportResource(Resource): """Base resource for passport.""" + @web_ns.doc("get_passport") + @web_ns.doc(description="Get authentication passport for web application access") + @web_ns.doc( + responses={ + 200: "Passport retrieved successfully", + 401: "Unauthorized - missing app code or invalid authentication", + 404: "Application or user not found", + } + ) def get(self): system_features = FeatureService.get_system_features() app_code = request.headers.get("X-App-Code") @@ -94,9 +104,6 @@ class PassportResource(Resource): } -api.add_resource(PassportResource, "/passport") - - def decode_enterprise_webapp_user_id(jwt_token: str | None): """ Decode the enterprise user session from the Authorization header. diff --git a/api/controllers/web/remote_files.py b/api/controllers/web/remote_files.py index 930b9d96e..ab20c7667 100644 --- a/api/controllers/web/remote_files.py +++ b/api/controllers/web/remote_files.py @@ -10,16 +10,44 @@ from controllers.common.errors import ( RemoteFileUploadError, UnsupportedFileTypeError, ) +from controllers.web import web_ns from controllers.web.wraps import WebApiResource from core.file import helpers as file_helpers from core.helper import ssrf_proxy -from fields.file_fields import file_fields_with_signed_url, remote_file_info_fields +from fields.file_fields import build_file_with_signed_url_model, build_remote_file_info_model from services.file_service import FileService +@web_ns.route("/remote-files/") class RemoteFileInfoApi(WebApiResource): - @marshal_with(remote_file_info_fields) + @web_ns.doc("get_remote_file_info") + @web_ns.doc(description="Get information about a remote file") + @web_ns.doc( + responses={ + 200: "Remote file information retrieved successfully", + 400: "Bad request - invalid URL", + 404: "Remote file not found", + 500: "Failed to fetch remote file", + } + ) + @marshal_with(build_remote_file_info_model(web_ns)) def get(self, app_model, end_user, url): + """Get information about a remote file. + + Retrieves basic information about a file located at a remote URL, + including content type and content length. + + Args: + app_model: The associated application model + end_user: The end user making the request + url: URL-encoded path to the remote file + + Returns: + dict: Remote file information including type and length + + Raises: + HTTPException: If the remote file cannot be accessed + """ decoded_url = urllib.parse.unquote(url) resp = ssrf_proxy.head(decoded_url) if resp.status_code != httpx.codes.OK: @@ -32,9 +60,42 @@ class RemoteFileInfoApi(WebApiResource): } +@web_ns.route("/remote-files/upload") class RemoteFileUploadApi(WebApiResource): - @marshal_with(file_fields_with_signed_url) - def post(self, app_model, end_user): # Add app_model and end_user parameters + @web_ns.doc("upload_remote_file") + @web_ns.doc(description="Upload a file from a remote URL") + @web_ns.doc( + responses={ + 201: "Remote file uploaded successfully", + 400: "Bad request - invalid URL or parameters", + 413: "File too large", + 415: "Unsupported file type", + 500: "Failed to fetch remote file", + } + ) + @marshal_with(build_file_with_signed_url_model(web_ns)) + def post(self, app_model, end_user): + """Upload a file from a remote URL. + + Downloads a file from the provided remote URL and uploads it + to the platform storage for use in web applications. + + Args: + app_model: The associated application model + end_user: The end user making the request + + JSON Parameters: + url: The remote URL to download the file from (required) + + Returns: + dict: File information including ID, signed URL, and metadata + int: HTTP status code 201 for success + + Raises: + RemoteFileUploadError: Failed to fetch file from remote URL + FileTooLargeError: File exceeds size limit + UnsupportedFileTypeError: File type not supported + """ parser = reqparse.RequestParser() parser.add_argument("url", type=str, required=True, help="URL is required") args = parser.parse_args()