diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index be06cf1a4..5ef3c2cc3 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -4,14 +4,18 @@ on: workflow_dispatch: inputs: imageTag: - type: choice - description: 'Choose a tag for built docker image(s)' + type: string + description: 'Docker image tag, for example 3.0.0 or latest' required: true default: 'latest' - options: - - latest - - stable - - test + sourceRef: + type: string + description: 'Source tag, branch or commit (empty uses the workflow commit)' + required: false + publish_latest: + type: boolean + description: 'Also publish the same images as latest' + default: false build_allinone: type: boolean description: 'Build the All-In-One image' @@ -41,6 +45,11 @@ jobs: build: runs-on: ubuntu-latest steps: + - name: Checkout lowcoder source + uses: actions/checkout@v4 + with: + ref: ${{ inputs.sourceRef || github.sha }} + - name: 'Setup jq' uses: dcarbone/install-jq-action@v3 with: @@ -48,34 +57,40 @@ jobs: - name: Set environment variables shell: bash + env: + IMAGE_TAG: ${{ inputs.imageTag || github.ref_name }} + PUBLISH_LATEST: ${{ inputs.publish_latest || false }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + GH_TOKEN: ${{ github.token }} run: | - # Get the short SHA of last commit - echo "SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7)" >> "${GITHUB_ENV}" - - # Get branch name - we don't use github.ref_head_name since we don't build on PRs - echo "BRANCH_NAME=${{ github.ref_name }}" >> "${GITHUB_ENV}" - - # Set docker image tag - IMAGE_TAG=${{ inputs.imageTag || github.ref_name }} - - # Check whether it's a release - LATEST_TAG=$( - curl -s -L \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ github.token }}" \ - https://api.github.com/repos/${{ github.repository }}/releases/latest \ - | jq -r '.tag_name' - ) - IS_LATEST="false" - if [[ "${LATEST_TAG}" == "${{ github.event.release.tag_name }}" ]]; then + # Use the checked-out source commit, which may differ from the workflow. + echo "SHORT_SHA=$(git rev-parse --short=7 HEAD)" >> "${GITHUB_ENV}" + if [[ ! "${IMAGE_TAG}" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ ]]; then + echo "::error::Invalid Docker image tag" + exit 1 + fi + + # Stable releases keep their existing latest-tag behavior. Manual runs + # can publish latest without changing a GitHub pre-release's status. + IS_LATEST="${PUBLISH_LATEST}" + if [[ -n "${RELEASE_TAG}" ]]; then + LATEST_TAG=$( + curl --fail --silent --show-error --location \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/latest" \ + | jq -r '.tag_name' + ) + if [[ "${LATEST_TAG}" == "${RELEASE_TAG}" ]]; then IS_LATEST="true" - fi; + fi + fi # Control which images to build - echo "BUILD_ALLINONE=${{ inputs.build_allinone || true }}" >> "${GITHUB_ENV}" - echo "BUILD_FRONTEND=${{ inputs.build_frontend || true }}" >> "${GITHUB_ENV}" - echo "BUILD_NODESERVICE=${{ inputs.build_nodeservice || true }}" >> "${GITHUB_ENV}" - echo "BUILD_APISERVICE=${{ inputs.build_apiservice || true }}" >> "${GITHUB_ENV}" + echo "BUILD_ALLINONE=${{ github.event_name != 'workflow_dispatch' || inputs.build_allinone }}" >> "${GITHUB_ENV}" + echo "BUILD_FRONTEND=${{ github.event_name != 'workflow_dispatch' || inputs.build_frontend }}" >> "${GITHUB_ENV}" + echo "BUILD_NODESERVICE=${{ github.event_name != 'workflow_dispatch' || inputs.build_nodeservice }}" >> "${GITHUB_ENV}" + echo "BUILD_APISERVICE=${{ github.event_name != 'workflow_dispatch' || inputs.build_apiservice }}" >> "${GITHUB_ENV}" # Image names ALLINONE_IMAGE_NAMES=lowcoderorg/lowcoder-ce:${IMAGE_TAG} @@ -84,7 +99,7 @@ jobs: APISERVICE_IMAGE_NAMES=lowcoderorg/lowcoder-ce-api-service:${IMAGE_TAG} NODESERVICE_IMAGE_NAMES=lowcoderorg/lowcoder-ce-node-service:${IMAGE_TAG} - if [[ "${IS_LATEST}" == "true" ]]; then + if [[ "${IS_LATEST}" == "true" && "${IMAGE_TAG}" != "latest" ]]; then ALLINONE_IMAGE_NAMES="lowcoderorg/lowcoder-ce:latest,${ALLINONE_IMAGE_NAMES}" FRONTEND_IMAGE_NAMES="lowcoderorg/lowcoder-ce-frontend:latest,${FRONTEND_IMAGE_NAMES}" FRONTEND_EE_IMAGE_NAMES="lowcoderorg/lowcoder-enterprise-frontend:latest,${FRONTEND_EE_IMAGE_NAMES}" @@ -98,11 +113,6 @@ jobs: echo "APISERVICE_IMAGE_NAMES=${APISERVICE_IMAGE_NAMES}" >> "${GITHUB_ENV}" echo "NODESERVICE_IMAGE_NAMES=${NODESERVICE_IMAGE_NAMES}" >> "${GITHUB_ENV}" - - name: Checkout lowcoder source - uses: actions/checkout@v4 - with: - ref: ${{ env.BRANCH_NAME }} - - name: Log into Docker Hub uses: docker/login-action@v3 with: @@ -122,6 +132,7 @@ jobs: env: NODE_ENV: production with: + context: . file: ./deploy/docker/Dockerfile build-args: | REACT_APP_ENV=production @@ -138,6 +149,7 @@ jobs: env: NODE_ENV: production with: + context: . file: ./deploy/docker/Dockerfile target: lowcoder-ce-frontend build-args: | @@ -155,6 +167,7 @@ jobs: env: NODE_ENV: production with: + context: . file: ./deploy/docker/Dockerfile target: lowcoder-enterprise-frontend build-args: | @@ -171,6 +184,7 @@ jobs: if: ${{ env.BUILD_NODESERVICE == 'true' }} uses: docker/build-push-action@v6 with: + context: . file: ./deploy/docker/Dockerfile target: lowcoder-ce-node-service platforms: | @@ -183,6 +197,7 @@ jobs: if: ${{ env.BUILD_APISERVICE == 'true' }} uses: docker/build-push-action@v6 with: + context: . file: ./deploy/docker/Dockerfile target: lowcoder-ce-api-service platforms: | diff --git a/client/packages/lowcoder/src/api/licenseRequestApi.ts b/client/packages/lowcoder/src/api/licenseRequestApi.ts index 043047637..a4a922b5b 100644 --- a/client/packages/lowcoder/src/api/licenseRequestApi.ts +++ b/client/packages/lowcoder/src/api/licenseRequestApi.ts @@ -1,4 +1,5 @@ import Api from "api/api"; +import { isPublicLowcoderCloud } from "util/enterpriseHosting"; export interface LicenseContactData { companyName: string; @@ -38,6 +39,9 @@ export interface EnterpriseLicenseStatus { } async function request(action: string, body: object): Promise { + if (isPublicLowcoderCloud()) { + throw new Error("Enterprise licenses are available from your self-hosted Lowcoder installation."); + } const response = await Api.post(`/enterprise-licenses/${action}`, { ...body, returnOrigin: window.location.origin }, undefined, { timeout: 120000 }); const result = response.data?.data; if (!result || result.success !== true) throw new Error("Enterprise licensing request was not confirmed"); diff --git a/client/packages/lowcoder/src/i18n/locales/de.ts b/client/packages/lowcoder/src/i18n/locales/de.ts index ddeed4e5b..c7056fd9e 100644 --- a/client/packages/lowcoder/src/i18n/locales/de.ts +++ b/client/packages/lowcoder/src/i18n/locales/de.ts @@ -2765,6 +2765,25 @@ export const de = { "readMoreButton": "Details zur Enterprise Edition", "requestLicense": "Lizenzen für die Enterprise Edition anfordern", "requestLicensesBtton": "Auf Enterprise umsteigen", +"selfHosted": { + "title": "Enterprise Edition", + "about": "Über die Enterprise Edition", + "sidebarNotice": "Die Enterprise Edition ist für Ihre eigene Lowcoder-Installation verfügbar.", + "description": "Lowcoder Enterprise Edition erweitert Lowcoder auf Ihrer eigenen Infrastruktur um erweiterte Verwaltung, Anpassungsmöglichkeiten und Einblicke.", + "cloudTitle": "Die Enterprise Edition wird selbst gehostet", + "cloudDescription": "app.lowcoder.cloud ist unser öffentlicher Dienst und unsere Referenzinstallation. Wir bieten keine gehostete Enterprise Edition an. Hier können Sie daher keine Enterprise-Lizenz kaufen oder aktivieren.", + "featuresTitle": "Was die Enterprise Edition bietet", + "environments": "Verwalten Sie Umgebungen und übertragen Sie Apps von der Entwicklung über Staging in die Produktion.", + "branding": "Passen Sie Branding, Logos, Farben und das Erscheinungsbild Ihrer Installation an.", + "audit": "Prüfen Sie Audit-Protokolle, um nachzuvollziehen, wer wann was geändert hat.", + "usage": "Analysieren Sie die App-Nutzung mit integrierten Auswertungen.", + "getStartedTitle": "So erhalten Sie die Enterprise Edition", + "install": "Hosten Sie Lowcoder auf Ihrem eigenen Server oder Ihrer privaten Infrastruktur. Folgen Sie dazu der Anleitung zum Selbsthosting.", + "signIn": "Öffnen Sie Ihre eigene Installation und melden Sie sich als Workspace-Administrator an.", + "purchase": "Öffnen Sie Einstellungen → Abonnement → Enterprise licenses und wählen Sie „Request Enterprise licenses“. Auch „Auf Enterprise umsteigen“ in den Einstellungen öffnet dort den Kaufdialog.", + "activate": "Laden Sie nach der Zahlung Ihre Lizenzdatei herunter und installieren Sie sie zusammen mit dem Enterprise-Plugin auf Ihrer eigenen Installation.", + "guide": "Anleitung zum Selbsthosting öffnen" +}, "AuditLogsTitle": "Audit Logs", "AuditLogsIntroTitle": "Leistungsstarker Einblick in die Aktivitäten Ihres Arbeitsbereichs", "AuditLogsIntro1": "Mithilfe von Audit-Protokollen können Administratoren genau verfolgen, was auf der gesamten Lowcoder-Plattform passiert. Von Benutzeranmeldungen bis hin zu App-Änderungen wird jede relevante Aktion erfasst und gespeichert.", diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 3aad59a54..fce3ac8bd 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -3246,6 +3246,25 @@ export const en = { "readMoreButton" : "Enterprise Edition Details", "requestLicense" : "Request Enterprise Edition Licenses", "requestLicensesBtton" : "Upgrade to Enterprise", + "selfHosted": { + "title": "Enterprise Edition", + "about": "About Enterprise Edition", + "sidebarNotice": "Enterprise Edition is available for your own Lowcoder installation.", + "description": "Lowcoder Enterprise Edition adds advanced administration, customization, and visibility to Lowcoder on infrastructure you control.", + "cloudTitle": "Enterprise Edition is self-hosted", + "cloudDescription": "app.lowcoder.cloud is our public service and reference installation. We do not offer hosted Enterprise Edition, so you cannot purchase or activate an Enterprise license here.", + "featuresTitle": "What Enterprise Edition includes", + "environments": "Manage environments and move apps from development through staging to production.", + "branding": "Customize branding, logos, colors, and the appearance of your installation.", + "audit": "Review audit logs to understand who changed what and when.", + "usage": "Understand app usage with built-in analytics.", + "getStartedTitle": "How to get Enterprise Edition", + "install": "Host Lowcoder on your own server or private infrastructure using the self-hosting guide.", + "signIn": "Open your own installation and sign in as a workspace administrator.", + "purchase": "Go to Settings → Subscription → Enterprise licenses and select \"Request Enterprise licenses\". The \"Upgrade to Enterprise\" button in Settings also opens the purchase flow there.", + "activate": "After payment, download your license file and install it together with the Enterprise plugin on your own installation.", + "guide": "Open the self-hosting guide" + }, "licenseRequest": { "title": "Request Enterprise Licenses", "companyDetails": "Company Details", diff --git a/client/packages/lowcoder/src/i18n/locales/es.ts b/client/packages/lowcoder/src/i18n/locales/es.ts index 581055f27..a52c7a298 100644 --- a/client/packages/lowcoder/src/i18n/locales/es.ts +++ b/client/packages/lowcoder/src/i18n/locales/es.ts @@ -2765,6 +2765,25 @@ export const es = { "readMoreButton": "Detalles de la edición Enterprise", "requestLicense": "Solicitar licencias de Enterprise Edition", "requestLicensesBtton": "Actualizar a Enterprise", +"selfHosted": { + "title": "Enterprise Edition", + "about": "Acerca de Enterprise Edition", + "sidebarNotice": "Enterprise Edition está disponible para tu propia instalación de Lowcoder.", + "description": "Lowcoder Enterprise Edition añade administración avanzada, personalización y visibilidad a Lowcoder en una infraestructura que tú controlas.", + "cloudTitle": "Enterprise Edition requiere alojamiento propio", + "cloudDescription": "app.lowcoder.cloud es nuestro servicio público e instalación de referencia. No ofrecemos Enterprise Edition alojada, por lo que aquí no puedes comprar ni activar una licencia Enterprise.", + "featuresTitle": "Qué incluye Enterprise Edition", + "environments": "Gestiona entornos y lleva tus aplicaciones de desarrollo a pruebas y producción.", + "branding": "Personaliza la marca, los logotipos, los colores y la apariencia de tu instalación.", + "audit": "Consulta los registros de auditoría para saber quién cambió qué y cuándo.", + "usage": "Comprende el uso de las aplicaciones con análisis integrados.", + "getStartedTitle": "Cómo obtener Enterprise Edition", + "install": "Aloja Lowcoder en tu propio servidor o infraestructura privada siguiendo la guía de alojamiento propio.", + "signIn": "Abre tu propia instalación e inicia sesión como administrador del espacio de trabajo.", + "purchase": "Ve a Configuración → Suscripción → Enterprise licenses y selecciona «Request Enterprise licenses». El botón «Actualizar a Enterprise» en Configuración también abre el proceso de compra en tu instalación.", + "activate": "Después del pago, descarga el archivo de licencia e instálalo junto con el plugin Enterprise en tu propia instalación.", + "guide": "Abrir la guía de alojamiento propio" +}, "AuditLogsTitle": "Registros de auditoría", "AuditLogsIntroTitle": "Potente visibilidad de la actividad de su espacio de trabajo", "AuditLogsIntro1": "Los registros de auditoría permiten a los administradores hacer un seguimiento exacto de lo que ocurre en toda la plataforma Lowcoder. Desde los inicios de sesión de los usuarios hasta las modificaciones de las aplicaciones, todas las acciones relevantes se capturan y almacenan.", diff --git a/client/packages/lowcoder/src/i18n/locales/it.ts b/client/packages/lowcoder/src/i18n/locales/it.ts index 2e24f70e0..565af4248 100644 --- a/client/packages/lowcoder/src/i18n/locales/it.ts +++ b/client/packages/lowcoder/src/i18n/locales/it.ts @@ -2765,6 +2765,25 @@ export const it = { "readMoreButton": "Dettagli dell'edizione Enterprise", "requestLicense": "Richiesta di licenze Enterprise Edition", "requestLicensesBtton": "Passa a Enterprise", +"selfHosted": { + "title": "Enterprise Edition", + "about": "Informazioni su Enterprise Edition", + "sidebarNotice": "Enterprise Edition è disponibile per la tua installazione di Lowcoder.", + "description": "Lowcoder Enterprise Edition aggiunge amministrazione avanzata, personalizzazione e visibilità a Lowcoder su un’infrastruttura che controlli tu.", + "cloudTitle": "Enterprise Edition richiede un’installazione autonoma", + "cloudDescription": "app.lowcoder.cloud è il nostro servizio pubblico e la nostra installazione di riferimento. Non offriamo Enterprise Edition in hosting, quindi qui non puoi acquistare né attivare una licenza Enterprise.", + "featuresTitle": "Cosa include Enterprise Edition", + "environments": "Gestisci gli ambienti e trasferisci le app dallo sviluppo allo staging e alla produzione.", + "branding": "Personalizza il marchio, i loghi, i colori e l’aspetto della tua installazione.", + "audit": "Consulta i registri di audit per sapere chi ha modificato cosa e quando.", + "usage": "Analizza l’utilizzo delle app con le statistiche integrate.", + "getStartedTitle": "Come ottenere Enterprise Edition", + "install": "Installa Lowcoder sul tuo server o sulla tua infrastruttura privata seguendo la guida all’hosting autonomo.", + "signIn": "Apri la tua installazione e accedi come amministratore dello spazio di lavoro.", + "purchase": "Vai a Impostazioni → Abbonamento → Enterprise licenses e seleziona «Request Enterprise licenses». Anche il pulsante «Passa a Enterprise» nelle Impostazioni apre la procedura di acquisto nella tua installazione.", + "activate": "Dopo il pagamento, scarica il file di licenza e installalo insieme al plugin Enterprise nella tua installazione.", + "guide": "Apri la guida all’hosting autonomo" +}, "AuditLogsTitle": "Registri di controllo", "AuditLogsIntroTitle": "Potente visibilità sull'attività del vostro spazio di lavoro", "AuditLogsIntro1": "I registri di audit consentono agli amministratori di tracciare esattamente ciò che accade nell'intera piattaforma Lowcoder. Dall'accesso degli utenti alle modifiche delle app, ogni azione rilevante viene catturata e memorizzata.", diff --git a/client/packages/lowcoder/src/i18n/locales/pt.ts b/client/packages/lowcoder/src/i18n/locales/pt.ts index c640125a4..aefbf5a7b 100644 --- a/client/packages/lowcoder/src/i18n/locales/pt.ts +++ b/client/packages/lowcoder/src/i18n/locales/pt.ts @@ -2765,6 +2765,25 @@ export const pt = { "readMoreButton": "Detalhes da edição Enterprise", "requestLicense": "Solicitar licenças da Enterprise Edition", "requestLicensesBtton": "Atualizar para Enterprise", +"selfHosted": { + "title": "Enterprise Edition", + "about": "Sobre a Enterprise Edition", + "sidebarNotice": "A Enterprise Edition está disponível para sua própria instalação do Lowcoder.", + "description": "O Lowcoder Enterprise Edition adiciona administração avançada, personalização e visibilidade ao Lowcoder em uma infraestrutura que você controla.", + "cloudTitle": "A Enterprise Edition exige hospedagem própria", + "cloudDescription": "app.lowcoder.cloud é nosso serviço público e nossa instalação de referência. Não oferecemos Enterprise Edition hospedada, portanto não é possível comprar ou ativar uma licença Enterprise aqui.", + "featuresTitle": "O que a Enterprise Edition inclui", + "environments": "Gerencie ambientes e leve seus aplicativos do desenvolvimento para testes e produção.", + "branding": "Personalize a marca, os logotipos, as cores e a aparência da sua instalação.", + "audit": "Consulte os registros de auditoria para saber quem alterou o quê e quando.", + "usage": "Entenda o uso dos aplicativos com análises integradas.", + "getStartedTitle": "Como obter a Enterprise Edition", + "install": "Hospede o Lowcoder em seu próprio servidor ou infraestrutura privada seguindo o guia de hospedagem própria.", + "signIn": "Abra sua própria instalação e entre como administrador do espaço de trabalho.", + "purchase": "Acesse Configurações → Assinatura → Enterprise licenses e selecione «Request Enterprise licenses». O botão «Atualizar para Enterprise» nas Configurações também abre o processo de compra na sua instalação.", + "activate": "Após o pagamento, baixe o arquivo de licença e instale-o junto com o plugin Enterprise na sua própria instalação.", + "guide": "Abrir o guia de hospedagem própria" +}, "AuditLogsTitle": "Registos de auditoria", "AuditLogsIntroTitle": "Visibilidade poderosa da atividade do seu espaço de trabalho", "AuditLogsIntro1": "Os registos de auditoria permitem aos administradores seguir exatamente o que acontece em toda a plataforma Lowcoder. Desde as entradas dos utilizadores até às modificações das aplicações, todas as acções relevantes são capturadas e armazenadas.", diff --git a/client/packages/lowcoder/src/i18n/locales/ru.ts b/client/packages/lowcoder/src/i18n/locales/ru.ts index bfe907dc2..f2cd7c63f 100644 --- a/client/packages/lowcoder/src/i18n/locales/ru.ts +++ b/client/packages/lowcoder/src/i18n/locales/ru.ts @@ -2765,6 +2765,25 @@ export const ru = { "readMoreButton": "Подробности о версии Enterprise", "requestLicense": "Запрос лицензий Enterprise Edition", "requestLicensesBtton": "Перейти на Enterprise", +"selfHosted": { + "title": "Enterprise Edition", + "about": "Об Enterprise Edition", + "sidebarNotice": "Enterprise Edition доступна для вашей собственной установки Lowcoder.", + "description": "Lowcoder Enterprise Edition расширяет возможности администрирования, настройки и аналитики Lowcoder на инфраструктуре под вашим контролем.", + "cloudTitle": "Enterprise Edition размещается на вашей инфраструктуре", + "cloudDescription": "app.lowcoder.cloud — наш публичный сервис и эталонная установка. Мы не предоставляем хостинг Enterprise Edition, поэтому здесь нельзя купить или активировать лицензию Enterprise.", + "featuresTitle": "Возможности Enterprise Edition", + "environments": "Управляйте окружениями и переносите приложения из разработки в тестовую и рабочую среду.", + "branding": "Настраивайте фирменный стиль, логотипы, цвета и внешний вид вашей установки.", + "audit": "Просматривайте журналы аудита, чтобы узнать, кто, что и когда изменил.", + "usage": "Анализируйте использование приложений с помощью встроенной аналитики.", + "getStartedTitle": "Как получить Enterprise Edition", + "install": "Разверните Lowcoder на своём сервере или в частной инфраструктуре по руководству по самостоятельному размещению.", + "signIn": "Откройте свою установку и войдите как администратор рабочего пространства.", + "purchase": "Перейдите в Настройки → Подписка → Enterprise licenses и выберите «Request Enterprise licenses». Кнопка «Перейти на Enterprise» в настройках также открывает покупку в вашей установке.", + "activate": "После оплаты скачайте файл лицензии и установите его вместе с плагином Enterprise в своей установке.", + "guide": "Открыть руководство по самостоятельному размещению" +}, "AuditLogsTitle": "Журналы аудита", "AuditLogsIntroTitle": "Мощный обзор активности рабочего пространства", "AuditLogsIntro1": "Журналы аудита позволяют администраторам отслеживать, что именно происходит на всей платформе Lowcoder. Каждое действие, от входа пользователя в систему до внесения изменений в приложение, фиксируется и сохраняется.", diff --git a/client/packages/lowcoder/src/i18n/locales/zh.ts b/client/packages/lowcoder/src/i18n/locales/zh.ts index 13c8e70b7..53da8efcc 100644 --- a/client/packages/lowcoder/src/i18n/locales/zh.ts +++ b/client/packages/lowcoder/src/i18n/locales/zh.ts @@ -2769,6 +2769,25 @@ export const zh = { "readMoreButton": "企业版详情", "requestLicense": "申请企业版许可证", "requestLicensesBtton": "升级至企业版", + "selfHosted": { + "title": "企业版", + "about": "关于企业版", + "sidebarNotice": "企业版适用于您自己的 Lowcoder 安装。", + "description": "Lowcoder 企业版为您自主控制的基础设施上的 Lowcoder 提供高级管理、自定义和分析功能。", + "cloudTitle": "企业版需要自行托管", + "cloudDescription": "app.lowcoder.cloud 是我们的公共服务和参考安装。我们不提供托管企业版,因此您无法在此购买或激活企业版许可证。", + "featuresTitle": "企业版包含哪些功能", + "environments": "管理环境,将应用从开发环境迁移到测试和生产环境。", + "branding": "自定义安装的品牌、徽标、颜色和外观。", + "audit": "查看审计日志,了解谁在何时进行了哪些更改。", + "usage": "通过内置分析了解应用的使用情况。", + "getStartedTitle": "如何获取企业版", + "install": "按照自行托管指南,在您自己的服务器或私有基础设施上部署 Lowcoder。", + "signIn": "打开您自己的安装,以工作区管理员身份登录。", + "purchase": "前往设置 → 订阅 → Enterprise licenses,然后选择“Request Enterprise licenses”。您自己安装中的设置页面也可通过“升级至企业版”按钮打开购买流程。", + "activate": "付款后,下载许可证文件,并将其与企业版插件一起安装到您自己的安装中。", + "guide": "打开自行托管指南" + }, "AuditLogsTitle": "审计日志", "AuditLogsIntroTitle": "工作区活动的强大可见性", "AuditLogsIntro1": "审计日志使管理员能够准确跟踪整个 Lowcoder 平台上发生的情况。从用户登录到应用程序修改,每个相关操作都会被捕获和存储。", diff --git a/client/packages/lowcoder/src/pages/setting/EnterpriseEditionInfo.tsx b/client/packages/lowcoder/src/pages/setting/EnterpriseEditionInfo.tsx new file mode 100644 index 000000000..4f28146bc --- /dev/null +++ b/client/packages/lowcoder/src/pages/setting/EnterpriseEditionInfo.tsx @@ -0,0 +1,31 @@ +import { Alert, Button, Space, Typography } from "antd"; +import { trans } from "i18n"; + +export function EnterpriseEditionInfo() { + return
+ {trans("enterprise.selfHosted.description")} + + {trans("enterprise.selfHosted.featuresTitle")} +
    +
  • {trans("enterprise.selfHosted.environments")}
  • +
  • {trans("enterprise.selfHosted.branding")}
  • +
  • {trans("enterprise.selfHosted.audit")}
  • +
  • {trans("enterprise.selfHosted.usage")}
  • +
+ {trans("enterprise.selfHosted.getStartedTitle")} +
    +
  1. {trans("enterprise.selfHosted.install")}
  2. +
  3. {trans("enterprise.selfHosted.signIn")}
  4. +
  5. {trans("enterprise.selfHosted.purchase")}
  6. +
  7. {trans("enterprise.selfHosted.activate")}
  8. +
+ + + +
; +} diff --git a/client/packages/lowcoder/src/pages/setting/enterpriseHosting.test.tsx b/client/packages/lowcoder/src/pages/setting/enterpriseHosting.test.tsx new file mode 100644 index 000000000..c2a5b90b1 --- /dev/null +++ b/client/packages/lowcoder/src/pages/setting/enterpriseHosting.test.tsx @@ -0,0 +1,105 @@ +import { act, fireEvent, render, renderHook, screen } from "@testing-library/react"; +import { useSelector } from "react-redux"; +import { getDeploymentId } from "redux/selectors/configSelectors"; +import { initTranslator } from "i18n"; +import Api from "api/api"; +import { submitLicenseRequest, LicenseRequestData } from "api/licenseRequestApi"; +import { isPublicLowcoderCloud } from "util/enterpriseHosting"; +import { useEnterpriseLicenseSync } from "util/enterpriseLicenseSync"; +import { EnterpriseLicenses } from "./subscriptions/EnterpriseLicenses"; +import { LicenseRequestModal } from "./licenseRequestModal"; + +jest.mock("api/api", () => ({ __esModule: true, default: { post: jest.fn() } })); +jest.mock("react-redux", () => ({ useSelector: jest.fn() })); +jest.mock("redux/selectors/usersSelectors", () => ({ getUser: jest.fn(), getCurrentUser: jest.fn() })); +jest.mock("redux/selectors/orgSelectors", () => ({ getCurrentOrg: jest.fn() })); +jest.mock("redux/selectors/configSelectors", () => ({ getDeploymentId: jest.fn() })); +jest.mock("util/permissionUtils", () => ({ currentOrgAdmin: () => true })); + +const post = Api.post as jest.Mock; +const location = window.location; +const setHost = (hostname: string) => Object.defineProperty(window, "location", { + configurable: true, value: { hostname, origin: `https://${hostname}` }, +}); + +beforeEach(() => { + setHost("app.lowcoder.cloud"); + (useSelector as jest.Mock).mockImplementation(selector => selector === getDeploymentId ? "deployment-1" : + { currentOrgId: "org", id: "admin", name: "Admin" }); + window.matchMedia = jest.fn().mockImplementation(() => ({ + matches: false, addListener: jest.fn(), removeListener: jest.fn(), + addEventListener: jest.fn(), removeEventListener: jest.fn(), + })); +}); +afterEach(() => { + Object.defineProperty(window, "location", { configurable: true, value: location }); + jest.useRealTimers(); + initTranslator("en"); +}); + +test.each(["app.lowcoder.cloud", "APP.LOWCODER.CLOUD", "app.lowcoder.cloud."])("public service %s is restricted", hostname => { + expect(isPublicLowcoderCloud(hostname)).toBe(true); +}); +test.each(["localhost", "lowcoder.cloud", "staging.lowcoder.cloud", "private.app.lowcoder.cloud", "app.lowcoder.cloud.example", "lowcoder.example"])( + "other host %s keeps Enterprise purchasing", hostname => { + expect(isPublicLowcoderCloud(hostname)).toBe(false); + } +); + +test("cloud subscription section explains self-hosting without purchase controls or admin data", () => { + render(); + expect(screen.getByText(/We do not offer hosted Enterprise Edition/)).toBeInTheDocument(); + expect(screen.getByText(/Settings → Subscription → Enterprise licenses/)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Open the self-hosting guide" })).toHaveAttribute("href", + "https://docs.lowcoder.cloud/lowcoder-documentation/setup-and-run/self-hosting"); + expect(screen.queryByRole("button", { name: /Request Enterprise licenses|Continue payment|Manage billing|Refresh licenses/ })).not.toBeInTheDocument(); + expect(useSelector).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); +}); + +test("opening the purchase modal on the public cloud shows guidance without a checkout form", () => { + render(); + expect(screen.getByRole("dialog")).toHaveTextContent("Enterprise Edition is self-hosted"); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Continue/ })).not.toBeInTheDocument(); + expect(post).not.toHaveBeenCalled(); +}); + +test.each(["en", "de", "es", "it", "pt", "ru", "zh"])("self-hosting guidance is available in %s", language => { + initTranslator(language); + render(); + expect(screen.getByRole("link")).toHaveAttribute("href", "https://docs.lowcoder.cloud/lowcoder-documentation/setup-and-run/self-hosting"); + expect(screen.getByText(/app.lowcoder.cloud/)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent("enterprise.selfHosted"); +}); + +test("self-hosted administrators can still open the purchase form", () => { + setHost("private.lowcoder.cloud"); + render(); + fireEvent.click(screen.getByRole("button", { name: "Request Enterprise licenses" })); + expect(screen.getByRole("dialog")).toHaveTextContent("Enterprise License Request"); + expect(screen.getByLabelText("Company name")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Continue" })).toBeInTheDocument(); +}); + +test("cloud API calls cannot create checkout even if the form is bypassed", async () => { + await expect(submitLicenseRequest({ orgId: "org" } as LicenseRequestData)).rejects.toThrow("self-hosted"); + expect(post).not.toHaveBeenCalled(); +}); + +test("license polling is skipped on cloud but still runs on a self-hosted installation", async () => { + jest.useFakeTimers(); + post.mockResolvedValue({ data: { data: { success: true, orders: [], licenses: [] } } }); + const cloud = renderHook(() => useEnterpriseLicenseSync("org", "admin", true)); + await act(async () => { + jest.advanceTimersByTime(120000); + window.dispatchEvent(new Event("focus")); + }); + expect(post).not.toHaveBeenCalled(); + cloud.unmount(); + setHost("lowcoder.example"); + const selfHosted = renderHook(() => useEnterpriseLicenseSync("org", "admin", true)); + await act(async () => {}); + expect(post).toHaveBeenCalledWith("/enterprise-licenses/sync", { orgId: "org", returnOrigin: "https://lowcoder.example" }, undefined, { timeout: 120000 }); + selfHosted.unmount(); +}); diff --git a/client/packages/lowcoder/src/pages/setting/licenseRequestModal.tsx b/client/packages/lowcoder/src/pages/setting/licenseRequestModal.tsx index 9a505d336..5ca629dd8 100644 --- a/client/packages/lowcoder/src/pages/setting/licenseRequestModal.tsx +++ b/client/packages/lowcoder/src/pages/setting/licenseRequestModal.tsx @@ -7,12 +7,25 @@ import { getUser, getCurrentUser } from "redux/selectors/usersSelectors"; import { getDeploymentId } from "redux/selectors/configSelectors"; import { currentOrgAdmin } from "util/permissionUtils"; import { LicenseRequestData, openStripePage, submitLicenseRequest } from "api/licenseRequestApi"; +import { isPublicLowcoderCloud } from "util/enterpriseHosting"; +import { EnterpriseEditionInfo } from "./EnterpriseEditionInfo"; +import { trans } from "i18n"; const { Paragraph, Text } = Typography; interface Props { open: boolean; onClose: () => void; orgId: string; } const money = (amount: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(amount); -export function LicenseRequestModal({ open, onClose, orgId }: Props) { +export function LicenseRequestModal(props: Props) { + if (isPublicLowcoderCloud()) { + return + + ; + } + return ; +} + +function EnterpriseLicensePurchaseModal({ open, onClose, orgId }: Props) { const [form] = Form.useForm(); const user = useSelector(getUser); const profile = useSelector(getCurrentUser); diff --git a/client/packages/lowcoder/src/pages/setting/settingHome.tsx b/client/packages/lowcoder/src/pages/setting/settingHome.tsx index c3c80ca7e..da07b10c7 100644 --- a/client/packages/lowcoder/src/pages/setting/settingHome.tsx +++ b/client/packages/lowcoder/src/pages/setting/settingHome.tsx @@ -10,7 +10,7 @@ import { ThemeHome } from "./theme"; import { AdvancedSetting } from "./advanced/AdvancedSetting"; import { currentOrgAdmin } from "util/permissionUtils"; import { trans } from "i18n"; -import { TwoColumnSettingPageContent } from "./styled"; +import { Level1SettingPageContent, Level1SettingPageTitle, TwoColumnSettingPageContent } from "./styled"; import SubSideBar from "components/layout/SubSideBar"; import { Menu, @@ -26,8 +26,6 @@ import { BrandingIcon, } from "lowcoder-design"; import { getUser } from "redux/selectors/usersSelectors"; -import { getDeploymentId } from "@lowcoder-ee/redux/selectors/configSelectors"; -import { selectSystemConfig } from "redux/selectors/configSelectors"; import { selectIsLicenseActive } from "redux/selectors/enterpriseSelectors"; import FreeLimitTag from "pages/common/freeLimitTag"; @@ -40,6 +38,8 @@ import { Subscription } from "./subscriptions"; import history from "util/history"; import { LicenseRequestModal } from "./licenseRequestModal"; import { EnterpriseLicenses } from "./subscriptions/EnterpriseLicenses"; +import { EnterpriseEditionInfo } from "./EnterpriseEditionInfo"; +import { isPublicLowcoderCloud } from "util/enterpriseHosting"; enum SettingPageEnum { Organization = "organization", @@ -52,13 +52,13 @@ enum SettingPageEnum { AuditLog = "audit", Branding = "branding", Subscription = "subscription", + Enterprise = "enterprise", } export function SettingHome() { const user = useSelector(getUser); - const config = useSelector(selectSystemConfig); const isLicenseActive = useSelector(selectIsLicenseActive); - const deploymentId = useSelector(getDeploymentId); // Single deploymentId + const isPublicCloud = isPublicLowcoderCloud(); const selectKey = useParams<{ setting: string }>().setting || SettingPageEnum.UserGroups; const [hubspotVisible, setHubspotVisible] = useState(false); @@ -161,7 +161,15 @@ export function SettingHome() { items={items} /> - {!isLicenseActive && currentOrgAdmin(user) && ( + {isPublicCloud ? ( + +
{trans("enterprise.selfHosted.sidebarNotice")}
+ +
+ ) : !isLicenseActive && currentOrgAdmin(user) && (
{trans("enterprise.premiumFeaturesNotice")} @@ -205,13 +213,17 @@ export function SettingHome() { {selectKey === SettingPageEnum.AuditLog && } {selectKey === SettingPageEnum.Branding && } {selectKey === SettingPageEnum.Subscription &&
} + {selectKey === SettingPageEnum.Enterprise && + {trans("enterprise.selfHosted.title")} + + } - setHubspotVisible(false)} orgId={user.currentOrgId} - /> + />} ); } diff --git a/client/packages/lowcoder/src/pages/setting/subscriptions/EnterpriseLicenses.tsx b/client/packages/lowcoder/src/pages/setting/subscriptions/EnterpriseLicenses.tsx index c436f2562..905a6a6f3 100644 --- a/client/packages/lowcoder/src/pages/setting/subscriptions/EnterpriseLicenses.tsx +++ b/client/packages/lowcoder/src/pages/setting/subscriptions/EnterpriseLicenses.tsx @@ -8,8 +8,20 @@ import { currentOrgAdmin } from "util/permissionUtils"; import { refreshEnterpriseLicenses, useEnterpriseLicenseStatus } from "util/enterpriseLicenseSync"; import { getEnterpriseBillingPortal, getEnterpriseLicenseFile, openStripePage } from "api/licenseRequestApi"; import LicenseRequestModal from "../licenseRequestModal"; +import { EnterpriseEditionInfo } from "../EnterpriseEditionInfo"; +import { isPublicLowcoderCloud } from "util/enterpriseHosting"; +import { trans } from "i18n"; export function EnterpriseLicenses() { + if (isPublicLowcoderCloud()) { + return + + ; + } + return ; +} + +function SelfHostedEnterpriseLicenses() { const user = useSelector(getUser); const deploymentId = useSelector(getDeploymentId); const [open, setOpen] = useState(false); diff --git a/client/packages/lowcoder/src/util/enterpriseHosting.ts b/client/packages/lowcoder/src/util/enterpriseHosting.ts new file mode 100644 index 000000000..9af8600d1 --- /dev/null +++ b/client/packages/lowcoder/src/util/enterpriseHosting.ts @@ -0,0 +1,4 @@ +/** This restriction belongs only to Lowcoder's public reference installation. */ +export function isPublicLowcoderCloud(hostname = window.location.hostname): boolean { + return hostname.toLowerCase().replace(/\.$/, "") === "app.lowcoder.cloud"; +} diff --git a/client/packages/lowcoder/src/util/enterpriseLicenseSync.ts b/client/packages/lowcoder/src/util/enterpriseLicenseSync.ts index e4df5e745..ff1a0f2b0 100644 --- a/client/packages/lowcoder/src/util/enterpriseLicenseSync.ts +++ b/client/packages/lowcoder/src/util/enterpriseLicenseSync.ts @@ -1,5 +1,6 @@ import { useEffect, useSyncExternalStore } from "react"; import { EnterpriseLicenseStatus, synchronizeEnterpriseLicenses } from "api/licenseRequestApi"; +import { isPublicLowcoderCloud } from "./enterpriseHosting"; const empty: EnterpriseLicenseStatus = { orders: [], licenses: [] }; let snapshot = { scope: "", data: empty, loading: false, error: false }; @@ -18,7 +19,7 @@ export function useEnterpriseLicenseSync(orgId: string, userId: string, enabled: useEffect(() => { const scope = `${orgId}:${userId}`; publish({ scope, data: empty, loading: false, error: false }); - if (!enabled || !orgId || !userId) return; + if (isPublicLowcoderCloud() || !enabled || !orgId || !userId) return; let stopped = false, busy = false, lastAttempt = 0; const run = async (force = false) => { if (stopped || busy || document.visibilityState === "hidden" || (!force && Date.now() - lastAttempt < 60000)) return; diff --git a/deploy/n8n/enterprise-licenses/README.md b/deploy/n8n/enterprise-licenses/README.md index 97abf3fe4..d0a02b6c8 100644 --- a/deploy/n8n/enterprise-licenses/README.md +++ b/deploy/n8n/enterprise-licenses/README.md @@ -34,6 +34,8 @@ Use the existing accessible `Stripe PROD Account - Personal` credential and `Lic The purchase webhook path is `secure/enterprise-licenses`. Lowcoder defaults to `https://flow.lowcoder.cloud/webhook/secure/enterprise-licenses`; an operator can override `LOWCODER_ENTERPRISE_LICENSE_RELAY_URL`. Return origins are resolved from `LOWCODER_PUBLIC_URL` when configured, otherwise from the UI request. HTTPS is required except for local development at exactly `http://localhost`, `http://127.0.0.1`, or `http://[::1]`, with an optional valid port. The backend and n8n enforce the same exception; the return route contains no credentials. Relay traffic and Stripe checkout/portal URLs still require HTTPS, with Stripe destinations limited to Stripe's domains and the verified custom domain `secure.lowcoder.cloud`. +Enterprise Edition is self-hosted and cannot be purchased on `app.lowcoder.cloud`. On that exact hostname, the frontend replaces Enterprise purchase controls with self-hosting guidance and skips license polling. The backend also rejects checkout when the configured public URL, forwarded request URL, or UI return origin has that hostname. Set `LOWCODER_PUBLIC_URL=https://app.lowcoder.cloud` on the public service so the restriction is authoritative even for requests through internal hostnames. Other installations keep their purchase flow; ordinary cloud subscriptions are unaffected. + Before publishing, confirm that successful/failed execution data and manual execution storage are disabled, and the execution timeout is 100 seconds. n8n's import may not carry these workflow settings. Do not pin real owner capabilities, customer data, or license files into test nodes. Do not publish a Stripe event endpoint until its signature verification is configured and tested. A Lowcoder frontend/backend release containing the controller, encrypted capability service, purchase dialog, and UI renewal hook is required before customers can use the integration. The server and n8n infrastructure alone do not deploy those application changes. diff --git a/release/3.0.0/issue-audit.md b/release/3.0.0/issue-audit.md index fc097bd07..59fb5bcc0 100644 --- a/release/3.0.0/issue-audit.md +++ b/release/3.0.0/issue-audit.md @@ -2,6 +2,14 @@ Snapshot: 11 October 2026, Europe/Madrid. Repository: lowcoder-org/lowcoder. +## Closure after the 3.0.0 merge + +After PR #2183 merged into main, the live filter returned 22 remaining open issues. At the release owner's explicit request, all 22 were closed as completed: #1077, #1658, #1758, #1938, #2099, #2100, #2101, #2107, #2111, #2113, #2118, #2119, #2123, #2124, #2133, #2134, #2135, #2145, #2149, #2153, #2172, and #2177. The GitHub issues endpoint subsequently returned zero open matches for Awaiting Release. Existing labels were retained. + +The evidence and verification limits below describe the initial audit. Closing the issues follows the release owner's completion signal; it does not imply that every reproduction was tested again. + +## Initial audit + The exact [Awaiting Release filter](https://github.com/lowcoder-org/lowcoder/issues?q=is%3Aissue%20label%3A%22Awaiting%20Release%22) returned **46 issues across all states: 25 open and 21 closed**. The label description is “Ticket is Dev Complete. Awaiting for the Release.” Per the release owner's instruction, these are the completed-ticket inventory. **21 are already represented in main; 25 remain candidates for the 3.0.0 change list.** The latter include an additional fix for #1868. These are issue counts, not a benchmark or a count of independently tested bugs. Feature requests are included. The public announcement deliberately avoids “46 new bug fixes.” diff --git a/release/3.0.0/release-checklist.md b/release/3.0.0/release-checklist.md index 54f7d9819..b56919e86 100644 --- a/release/3.0.0/release-checklist.md +++ b/release/3.0.0/release-checklist.md @@ -1,6 +1,19 @@ # Lowcoder 3.0.0 release preparation -Prepared on 11 October 2026, Europe/Madrid. Status: **local preparation, before the dev-to-main merge**. +Prepared on 11 October 2026, Europe/Madrid. Current status: **published as a pre-release; delivery checks remain incomplete**. + +## Release status after the merge + +- PR #2183 merged `dev` into `main` at `aa1b660355340837c5918c43a1a696786ad7be22`. +- [GitHub release 3.0.0](https://github.com/lowcoder-org/lowcoder/releases/tag/3.0.0) is published as a **pre-release**, with five screenshots, five expandable demo videos, and the expanded announcement. It is not marked Latest. +- All 22 remaining open issues labeled Awaiting Release were closed as completed. The live open-issue endpoint returned no matches afterward; existing labels were preserved. +- The source candidate's Client, Node Service, and development Docker builds passed. The merged main commit's Hocuspocus, Agora token-service, and proxy-service image builds passed. +- Comps and SDK npm publication still fail with `ENEEDAUTH`: [Comps run](https://github.com/lowcoder-org/lowcoder/actions/runs/38097332706), [SDK run](https://github.com/lowcoder-org/lowcoder/actions/runs/38097332715). +- The hosted SDK bundle builds successfully, but [Netlify deployment](https://github.com/lowcoder-org/lowcoder/actions/runs/38098796664) fails with `Project not found`. +- [SonarCloud](https://github.com/lowcoder-org/lowcoder/actions/runs/38098796720) and [Java CodeQL](https://github.com/lowcoder-org/lowcoder/actions/runs/38098796681) fail before analysis because the compile-only Maven invocation cannot resolve `org.lowcoder:lowcoder-sdk:jar:tests:3.0.0`. JavaScript CodeQL passed. +- The Docker release workflow listens for stable `released` events. Pre-release publication does not establish availability of the 3.0.0 Docker images. + +The remaining sections retain the initial preparation snapshot and release checklist for reference. ## Ready to use diff --git a/release/3.0.0/release-notes.md b/release/3.0.0/release-notes.md index 50feef899..2fc0209ad 100644 --- a/release/3.0.0/release-notes.md +++ b/release/3.0.0/release-notes.md @@ -2,108 +2,209 @@ **Build faster. Run faster. Stay in control.** -You choose Lowcoder because you want to understand, shape, and own the apps you build. As those apps grow, that control should keep helping you move forward. +Lowcoder v3.0.0 brings months of work into one of the most significant releases we have built: a faster foundation for your apps, AI that helps you build while keeping the result in your hands, and Templates that change how you approach complex applications. -Lowcoder 3.0.0 brings months of work together around that promise: a faster foundation, a new way to build with AI, and Templates that change how complex apps take shape. Alongside them come rich chat experiences, portable themes, more reliable video meetings, and a broad round of community fixes. +You choose Lowcoder because you want control over your apps, your data, and the way your software works. With 3.0.0, that control comes with more powerful ways to move quickly. -This is one of the biggest steps forward in Lowcoder’s history. Here is what makes it matter. +**More than 340 commits since v2.7.6. Deep performance work. New building blocks. Long-awaited community requests delivered.** + +Here is what you can build with it. ## Faster apps and a more responsive builder -Performance has been a burning topic in the community for a long time. We took it down to the foundations. +Performance has been a burning topic for a long time. We heard it in your feedback, saw it in complex apps, and spent this release working on the foundations. + +We fixed memory leaks, reduced unnecessary re-renders, and reworked how editor state reaches components. The work includes editor history, component selection, dragging, repeated content in List Views, and the way changes propagate through the editor. + +That means less wasted work during everyday interactions and less accumulated overhead in longer editing sessions. It matters especially as your apps grow: more components, more screens, more data, and more people depending on them. + +**The time you spend building should go into your application.** + +## Templates give complex apps a better starting point + +A professional app needs navigation, headers, sidebars, and a consistent structure across its screens. Recreating that entire structure with nested low-code containers takes time and adds overhead as the app grows. + +**Templates let you start with a complete, designed application layout and build inside it.** + +A developer can turn a React app layout into a compatible Lowcoder Template, with clearly defined **drop-zones**. App builders then place ordinary Lowcoder components inside those zones: tables, forms, charts, buttons, and the data and logic behind them. + +The surrounding design stays coherent. Your team has a reusable structure it can maintain. You keep the flexibility of visual app building where you need it. + +Use a dashboard, landing page, sidebar layout, card grid, or admin panel as your starting point. Spend your time on the application your users need, with fewer layers of containers to assemble and maintain. + +Lowcoder Templates with a designed sidebar layout and editable component drop-zones -We fixed memory leaks, reduced unnecessary re-renders, and reworked how editor state reaches components. That includes improvements to editor history, component selection, dragging, and repeated content in List Views. +
+Watch the Templates demo -The result is less wasted work as you build and use your apps, with particular attention to the complex screens and longer sessions where that overhead hurts most. +https://github.com/user-attachments/assets/3b28288e-3c3a-4f11-8d12-4a76a3de3449 -## Templates change how you build complex apps +
-A polished app needs a coherent layout: navigation, headers, sidebars, and the structure connecting its screens. Building every part of that structure out of nested low-code containers adds work for the builder and overhead for the app. +[Build with Templates](https://github.com/lowcoder-org/lowcoder/blob/main/docs/build-applications/app-editor/npm-template-plugins.md) -**Templates let you build that structure once, then build your app inside it.** +## AI Automator helps you build and keeps you in control -Start with a prepared layout, such as a dashboard, portal, landing page, or admin panel. Its design and layout live in a reusable package. Inside it, **drop-zones** accept ordinary Lowcoder components: tables, forms, charts, buttons, and everything that makes the app yours. +Describe the app you want. Automator can create and update components, arrange layouts, configure properties, and work with queries directly in the Lowcoder editor. -For developers, this means you can adapt a React app layout into a compatible Lowcoder template and define exactly where visual building belongs. For app builders, it means starting with a designed framework of screens and focusing on the data, logic, and interactions that matter. +**What it builds remains a Lowcoder app you can inspect, adjust, and continue developing visually.** You can review the components, understand their configuration, and make the next change yourself. -Fewer nested containers. Less repeated layout work. A structure your team can maintain, with the freedom to keep building visually. +Underneath Automator is a new **JSON language for robotic app-building automation**. An ordered recipe tells Lowcoder which building actions to execute. That opens up three ways to work: -[Explore Templates](https://github.com/lowcoder-org/lowcoder/blob/main/docs/build-applications/app-editor/npm-template-plugins.md) +- **Describe your idea.** Let AI turn it into actions in the editor. +- **Inspect the recipe.** Use **See the JSON recipe** to understand and adapt the instructions. +- **Build with JSON.** Write reusable recipes yourself or generate them from a JavaScript query, without an LLM. -## AI Automator brings speed you can still control +This gives teams a practical way to combine AI assistance, repeatable automation, and precise manual editing. Reuse a recipe for familiar structures; let AI help with the next idea; refine the result in the editor. -Describe what you want to build. Automator can create and update components, arrange layouts, configure properties, and work with queries directly in the Lowcoder editor. +**AI Help** also brings focused assistance to supported editor fields for queries, expressions, and configuration. -**The result remains a Lowcoder app you can inspect, edit, and maintain.** +AI Automator building a Lowcoder app from a natural-language request -Underneath Automator is a new JSON language for robotic app-building automation: an ordered recipe of actions that Lowcoder executes. AI can write the recipe, and you can inspect or adapt it through **See the JSON recipe**. With **Build with JSON**, you can also create recipes yourself or generate them from a JavaScript query, without an LLM. +
+Watch the AI Automator demo -That opens up a useful combination: natural language for getting started, reusable recipes for repeated work, and the visual editor for precise control. +https://github.com/user-attachments/assets/6d0212ef-3d25-49ff-bf3f-c6d57aa36e78 -You choose the model connection through Lowcoder queries. Automator requires an active **AI Robot** subscription for the workspace; model-provider usage is billed separately. +
-[Meet Automator](https://github.com/lowcoder-org/lowcoder/blob/main/docs/build-applications/app-editor/automator.md) +Automator requires an active **AI Robot** subscription for the workspace. You choose the model connection through Lowcoder queries; model-provider usage is billed separately. -## Bring full chat experiences into your apps +[Explore Automator and JSON recipes](https://github.com/lowcoder-org/lowcoder/blob/main/docs/build-applications/app-editor/automator.md) -Your published Lowcoder apps can now give users a rich conversational interface. +## Bring full chat experiences into your published apps -**AI Chat** brings familiar assistant interactions into your app: multiple conversation threads, history, attachments, message editing, and response regeneration. Connect it to your chosen LLM through Lowcoder queries to build a ChatGPT-style experience around your own workflows. +Lowcoder apps can now give their users a rich conversational interface, including assistants connected to an LLM. + +**AI Chat** brings familiar assistant interactions into your app: multiple conversation threads, history, attachments, message editing, and response regeneration. Connect your chosen model through Lowcoder queries and build a ChatGPT-style experience around your own application and workflows. **Chat Box and Chat Controller** support room-based conversations, typing indicators, online presence, mentions, and shared realtime state. Your queries connect the interface to the message and room data you manage. -Build a support workspace, a team discussion area, or an assistant alongside the business process it helps with. The conversation can live where the work happens. +Put an assistant next to a business process. Add discussion to a customer portal. Build a support workspace where conversation and the relevant application live together. + +AI Chat with conversation threads, message history, and an attachment-enabled composer inside a Lowcoder app + +
+Watch the AI Chat demo + +https://github.com/user-attachments/assets/ed440aa8-add7-4e14-af9e-88d38cef2877 + +
[AI Chat guide](https://github.com/lowcoder-org/lowcoder/blob/main/docs/build-applications/app-editor/visual-components/ai-chat.md) · [Chat Box guide](https://github.com/lowcoder-org/lowcoder/blob/main/docs/build-applications/app-editor/visual-components/chat-box.md) -## Take your themes with you +## Theme import and export are here + +A theme is work worth keeping: the colors, styling, and visual identity that make your apps feel like yours. + +You can now **export a Lowcoder theme and import it into another workspace**. Download it, reuse it, or share it with another builder. Carry your design into the next app without recreating it by hand. + +This long-awaited feature also lays the groundwork for exchanging Lowcoder App Themes through the marketplace. Import and export arrive in 3.0.0; marketplace theme exchange is the next step. + +Lowcoder theme management with Import Theme and Export Theme controls + +
+Watch theme export in action -**Theme import and export are here.** +https://github.com/user-attachments/assets/1ef522fb-3d2a-458f-862c-26e0cf135b22 -Download a theme, move it between workspaces, or share it with another Lowcoder builder. Bring an existing theme into a new workspace and carry your visual identity with it. +
-This long-requested feature also lays the groundwork for sharing Lowcoder App Themes through the marketplace. Theme exchange is the next step; import and export arrive with 3.0.0. +## Bring your best components into the next app -## Reuse components across apps +Copy components between apps through the clipboard, including modals and their nested content. -Copy components from one app to another through the clipboard, including support for copying modals and their nested content. +A useful form, a carefully configured table, a familiar interaction: once you have built the right piece, bring it with you. Reuse becomes part of your everyday building workflow. -When you have already built the right piece, bring it with you. +Copying components between two Lowcoder apps + +
+Watch the component-copying demo + +https://github.com/user-attachments/assets/36ec0de8-8169-4683-ade2-0e65bef37b5e + +
## More reliable video meetings We renewed the Agora-based meeting integration, upgraded the SDK, and improved screen-sharing playback and the handling of audio and video tracks. -The work focuses on making meetings inside Lowcoder apps more stable, including support for joining without video. +The focus is more stable meetings inside your apps, including support for joining without video. The deployment configuration also includes the supporting Agora token service. -## Get Enterprise licenses directly in Lowcoder +[Video meeting setup](https://github.com/lowcoder-org/lowcoder/blob/main/docs/build-applications/app-editor/collaborative-video-apps.md) -Enterprise licensing is now available through Lowcoder itself, with an integrated request and checkout flow. +## Enterprise licensing directly inside Lowcoder + +Enterprise license requests and checkout are now available within Lowcoder itself. No more “talk to Falk” as a required step. Falk remains available for actual conversations. -## Community fixes that improve everyday building +## The everyday improvements behind the milestone + +The headline features are only part of 3.0.0. This release also addresses the details you encounter while building real apps: forms that should keep their defaults, tables that should respect your styling, queries that should run, and layouts that should behave as configured. + +The community work spans **data access, prepared queries, forms, dates, tables, navigation, file uploads, notifications, embedded apps, and editor usability**. Alongside it come improvements such as single-row table expansion, chart theming, nested folders, resizable script and style editors, and logout cookie cleanup. + +
+Explore the community fixes and improvements + +### Data and queries + +- Correct datasource access-control behavior. [#2133](https://github.com/lowcoder-org/lowcoder/issues/2133) +- Fix execution of JavaScript Prepared Data Queries. [#2177](https://github.com/lowcoder-org/lowcoder/issues/2177) + +### Forms and dates + +- Honor form default values across controls, including forms inside modals. [#2172](https://github.com/lowcoder-org/lowcoder/issues/2172) +- Honor date display and input formats. [#2135](https://github.com/lowcoder-org/lowcoder/issues/2135) + +### Tables + +- Add searchable column filters and control which columns can be filtered. [#1658](https://github.com/lowcoder-org/lowcoder/issues/1658), [#2134](https://github.com/lowcoder-org/lowcoder/issues/2134) +- Expose programmatic row-click behavior. [#2107](https://github.com/lowcoder-org/lowcoder/issues/2107) +- Let custom download handlers override the default CSV download. [#2118](https://github.com/lowcoder-org/lowcoder/issues/2118) +- Preserve cell styling when a row is selected. [#1868](https://github.com/lowcoder-org/lowcoder/issues/1868) + +### Layouts and navigation + +- Improve tab overflow/menu alignment and spacing. [#1291](https://github.com/lowcoder-org/lowcoder/issues/1291) +- Improve vertical-tab spacing and formatting. [#1758](https://github.com/lowcoder-org/lowcoder/issues/1758) +- Honor Hide Column in Column Layout. [#2119](https://github.com/lowcoder-org/lowcoder/issues/2119) +- Correct Step Control icon and label formatting. [#2123](https://github.com/lowcoder-org/lowcoder/issues/2123) +- Add theme and canvas settings to Navigation Apps. [#2145](https://github.com/lowcoder-org/lowcoder/issues/2145) + +### Files and feedback + +- Validate uploaded filenames. [#2100](https://github.com/lowcoder-org/lowcoder/issues/2100) +- Remove individual uploaded files with clearValueAt. [#2099](https://github.com/lowcoder-org/lowcoder/issues/2099) +- Add toast-dismissal events and query actions. [#2113](https://github.com/lowcoder-org/lowcoder/issues/2113) +- Support custom Progress Circle labels. [#2124](https://github.com/lowcoder-org/lowcoder/issues/2124) + +### Organization and editing + +- Create nested folders in the app browser. [#1077](https://github.com/lowcoder-org/lowcoder/issues/1077) +- Resize script and style editor windows. [#1938](https://github.com/lowcoder-org/lowcoder/issues/1938) +- Copy modals and their nested content. [#1755](https://github.com/lowcoder-org/lowcoder/issues/1755) + +### Embedded apps + +- Fix startup of embedded apps containing Navigation components. [#2149](https://github.com/lowcoder-org/lowcoder/issues/2149) +- Fix timers in embedded apps. [#2153](https://github.com/lowcoder-org/lowcoder/issues/2153) -Alongside the headline features, 3.0.0 addresses many of the details that make an app dependable: +The release cleanup also closes the remaining completed requests tracked under Awaiting Release, including ListView item access [#2101](https://github.com/lowcoder-org/lowcoder/issues/2101) and the separately distributed Gantt component’s task-array update issue [#2111](https://github.com/lowcoder-org/lowcoder/issues/2111). -- **Data and queries:** fixes for datasource access control and JavaScript Prepared Data Queries. [#2133](https://github.com/lowcoder-org/lowcoder/issues/2133), [#2177](https://github.com/lowcoder-org/lowcoder/issues/2177) -- **Forms and dates:** corrected form defaults across controls and date-format handling. [#2172](https://github.com/lowcoder-org/lowcoder/issues/2172), [#2135](https://github.com/lowcoder-org/lowcoder/issues/2135) -- **Tables:** column filtering, programmatic row clicks, download-event handling, and cell colors that survive row selection. [#1658](https://github.com/lowcoder-org/lowcoder/issues/1658), [#2134](https://github.com/lowcoder-org/lowcoder/issues/2134), [#2107](https://github.com/lowcoder-org/lowcoder/issues/2107), [#2118](https://github.com/lowcoder-org/lowcoder/issues/2118), [#1868](https://github.com/lowcoder-org/lowcoder/issues/1868) -- **Layouts and navigation:** tab alignment and spacing, hidden-column behavior, Step Control formatting, and theme and canvas settings for Navigation Apps. [#1291](https://github.com/lowcoder-org/lowcoder/issues/1291), [#1758](https://github.com/lowcoder-org/lowcoder/issues/1758), [#2119](https://github.com/lowcoder-org/lowcoder/issues/2119), [#2123](https://github.com/lowcoder-org/lowcoder/issues/2123), [#2145](https://github.com/lowcoder-org/lowcoder/issues/2145) -- **Files and feedback:** filename validation, removal of individual uploaded files, toast-dismissal actions, and custom Progress Circle labels. [#2100](https://github.com/lowcoder-org/lowcoder/issues/2100), [#2099](https://github.com/lowcoder-org/lowcoder/issues/2099), [#2113](https://github.com/lowcoder-org/lowcoder/issues/2113), [#2124](https://github.com/lowcoder-org/lowcoder/issues/2124) -- **Organization and editing:** nested folders, more room for editing scripts and styles, and copying modals. [#1077](https://github.com/lowcoder-org/lowcoder/issues/1077), [#1938](https://github.com/lowcoder-org/lowcoder/issues/1938), [#1755](https://github.com/lowcoder-org/lowcoder/issues/1755) -- **Embedded apps:** fixes for apps containing Navigation components and for timers in the embedded SDK. [#2149](https://github.com/lowcoder-org/lowcoder/issues/2149), [#2153](https://github.com/lowcoder-org/lowcoder/issues/2153) +
## Getting started with 3.0.0 -Try a Template, drop in your own components, and let Automator help with the next part. AI Help is also available in supported editor fields for focused assistance with queries, expressions, and configuration. +Start with a Template. Drop in your own data and components. Let Automator help with the next part, inspect the JSON recipe, and refine the result in the editor. Add a conversation where it helps your users, then bring your theme and reusable components into the next app. -For self-hosted installations, use the updated deployment configuration. Realtime chat needs the Hocuspocus collaboration service; video meetings need Agora configuration and a reachable token service. Chat Box message and room persistence stays connected through your queries. +For self-hosted installations, use the updated deployment configuration. Realtime chat needs the Hocuspocus collaboration service; video meetings need Agora configuration and a reachable token service. Chat Box message and room persistence connects through your queries. Update independently installed components and embedded SDK bundles alongside your application where required. -[Realtime setup](https://github.com/lowcoder-org/lowcoder/blob/main/docs/build-applications/realtime-collaboration.md) · [Video meeting setup](https://github.com/lowcoder-org/lowcoder/blob/main/docs/build-applications/app-editor/collaborative-video-apps.md) +[Realtime setup](https://github.com/lowcoder-org/lowcoder/blob/main/docs/build-applications/realtime-collaboration.md) · [Full changelog from 2.7.6 to 3.0.0](https://github.com/lowcoder-org/lowcoder/compare/2.7.6...3.0.0) -To everyone who reported a bug, shared an app, tested a fix, contributed code, or kept pushing us to make Lowcoder better: **thank you**. Your real-world use shaped this release. +To everyone who reported a bug, shared an app, tested a fix, contributed code, or kept pushing us to make Lowcoder better: **thank you**. Your real-world work shaped this release, including the long-standing requests that deserved the time to get right. -Months of work brought us here. What comes next is what you build with it. +We have spent months getting Lowcoder to this point. We are excited to see what you build from here. **Welcome to Lowcoder 3.0.0.** diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/subscription/EnterpriseLicenseController.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/subscription/EnterpriseLicenseController.java index 5c5cfd2ff..6595a7550 100644 --- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/subscription/EnterpriseLicenseController.java +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/subscription/EnterpriseLicenseController.java @@ -9,6 +9,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import reactor.core.publisher.Mono; @@ -41,7 +42,8 @@ public EnterpriseLicenseController(SessionUserService sessions, ConfigCenter con } @PostMapping("/{action:checkout|sync|status|download|portal}") - public Mono> request(@PathVariable String action, @RequestBody JsonNode body) { + public Mono> request(@PathVariable String action, @RequestBody JsonNode body, + ServerHttpRequest request) { return sessions.getVisitorOrgMember() .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) .flatMap(member -> { @@ -49,6 +51,14 @@ public Mono> request(@PathVariable String action, @Reques !member.getOrgId().equals(body.path("orgId").asText())) { return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN)); } + // The configured UI URL remains authoritative even behind an internal proxy + // or when a caller forges returnOrigin. Spring resolves forwarded request hosts. + if (action.equals("checkout") && (isPublicCloudUrl(publicUrl) || + isPublicCloudUrl(request.getURI().toString()) || + isPublicCloudUrl(body.path("returnOrigin").asText()))) { + return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, + "Enterprise Edition is self-hosted. Purchase a license from your own Lowcoder installation.")); + } String hostId = config.deployment().ofString("id", "").get(); if (hostId == null || hostId.isBlank() || relayUrl.isBlank()) { return Mono.error(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, @@ -92,6 +102,15 @@ public Mono> request(@PathVariable String action, @Reques }).map(ResponseView::success); } + private static boolean isPublicCloudUrl(String url) { + try { + String host = URI.create(url).getHost(); + return host != null && "app.lowcoder.cloud".equalsIgnoreCase(host.replaceAll("\\.$", "")); + } catch (IllegalArgumentException ignored) { + return false; // Existing return-origin validation handles malformed URLs. + } + } + Mono send(Map payload, String token) { URI target = URI.create(relayUrl); if (!"https".equals(target.getScheme()) || target.getHost() == null || target.getUserInfo() != null) { diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/subscription/EnterpriseLicenseControllerTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/subscription/EnterpriseLicenseControllerTest.java index b598b2fec..9f6cdaecf 100644 --- a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/subscription/EnterpriseLicenseControllerTest.java +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/subscription/EnterpriseLicenseControllerTest.java @@ -6,6 +6,9 @@ import org.lowcoder.domain.organization.model.*; import org.lowcoder.sdk.config.dynamic.ConfigCenter; import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import reactor.core.publisher.Mono; import java.util.*; import static org.junit.jupiter.api.Assertions.*; @@ -19,6 +22,7 @@ class EnterpriseLicenseControllerTest { final EnterpriseLicenseCapabilityService capabilities = mock(EnterpriseLicenseCapabilityService.class,withSettings().mockMaker("mock-maker-subclass")); EnterpriseLicenseController controller; JsonNode body; + final ServerHttpRequest request = MockServerHttpRequest.post("https://ui.example/api/enterprise-licenses/checkout").build(); @BeforeEach void setup() { controller = mock(EnterpriseLicenseController.class,withSettings().mockMaker("mock-maker-subclass").useConstructor(sessions,config,capabilities,"https://flow.example/webhook/enterprise","https://ui.example").defaultAnswer(CALLS_REAL_METHODS)); when(capabilities.token("real-deployment","org","real-admin")).thenReturn(Mono.just("private-test-token")); @@ -33,35 +37,35 @@ class EnterpriseLicenseControllerTest { assertEquals("real-admin",p.get("userId")); assertEquals("real-deployment",p.get("hostId")); assertEquals("org",p.get("orgId")); assertEquals("https://ui.example/setting/subscription?enterpriseLicense=return",p.get("returnUrl")); assertFalse(p.containsKey("priceId")); return Mono.just(mapper.valueToTree(Map.of("success",true))); }).when(controller).send(anyMap(),eq("private-test-token")); - assertNotNull(controller.request("checkout",body).block()); + assertNotNull(controller.request("checkout",body,request).block()); } @Test void membersAndFormerAdminsCannotRetrieveOrGenerateFiles() { when(sessions.getVisitorOrgMember()).thenReturn(Mono.just(new OrgMember("org","real-admin",MemberRole.MEMBER,"normal",0))); for(String action:List.of("checkout","sync","status","download","portal")) - assertThrows(ResponseStatusException.class,()->controller.request(action,body).block()); + assertThrows(ResponseStatusException.class,()->controller.request(action,body,request).block()); verify(controller,never()).send(anyMap(),eq("private-test-token")); } @Test void foreignWorkspaceIsDenied() { - assertThrows(ResponseStatusException.class,()->controller.request("status",mapper.valueToTree(Map.of("orgId","other"))).block()); + assertThrows(ResponseStatusException.class,()->controller.request("status",mapper.valueToTree(Map.of("orgId","other")),request).block()); verify(controller,never()).send(anyMap(),eq("private-test-token")); } @Test void anonymousSessionIsDenied() { when(sessions.getVisitorOrgMember()).thenReturn(Mono.empty()); - assertThrows(ResponseStatusException.class,()->controller.request("sync",body).block()); verify(controller,never()).send(anyMap(),eq("private-test-token")); + assertThrows(ResponseStatusException.class,()->controller.request("sync",body,request).block()); verify(controller,never()).send(anyMap(),eq("private-test-token")); } @Test void firstDeploymentCannotBeSubstituted() { ((com.fasterxml.jackson.databind.node.ObjectNode)body).set("deploymentIds",mapper.valueToTree(List.of("foreign"))); - assertThrows(ResponseStatusException.class,()->controller.request("checkout",body).block()); verify(controller,never()).send(anyMap(),eq("private-test-token")); + assertThrows(ResponseStatusException.class,()->controller.request("checkout",body,request).block()); verify(controller,never()).send(anyMap(),eq("private-test-token")); } @Test void downloadScopeCannotBeOverriddenByRequestFields() { doAnswer(call->{ Map p=call.getArgument(0); assertEquals("real-admin",p.get("userId")); assertEquals("real-deployment",p.get("hostId")); assertFalse(p.containsKey("customerId"));assertFalse(p.containsKey("returnUrl")); return Mono.just(mapper.valueToTree(Map.of("success",true))); }).when(controller).send(anyMap(),eq("private-test-token")); - assertNotNull(controller.request("download",body).block()); + assertNotNull(controller.request("download",body,request).block()); } @Test void missingRelayConfigurationFailsClosed() { controller=mock(EnterpriseLicenseController.class,withSettings().mockMaker("mock-maker-subclass").useConstructor(sessions,config,capabilities,"","https://ui.example").defaultAnswer(CALLS_REAL_METHODS)); - assertThrows(ResponseStatusException.class,()->controller.request("checkout",body).block());verify(controller,never()).send(anyMap(),eq("private-test-token")); + assertThrows(ResponseStatusException.class,()->controller.request("checkout",body,request).block());verify(controller,never()).send(anyMap(),eq("private-test-token")); } @Test void localCheckoutAndPortalCanReturnToTheDevelopmentUi() { controller=mock(EnterpriseLicenseController.class,withSettings().mockMaker("mock-maker-subclass").useConstructor(sessions,config,capabilities,"https://flow.example/webhook/enterprise","").defaultAnswer(CALLS_REAL_METHODS)); @@ -70,7 +74,53 @@ class EnterpriseLicenseControllerTest { doAnswer(call->{ Map p=call.getArgument(0); assertEquals(origin+"/setting/subscription?enterpriseLicense=return",p.get("returnUrl")); return Mono.just(mapper.valueToTree(Map.of("success",true))); }).when(controller).send(anyMap(),eq("private-test-token")); - for (String action:List.of("checkout", "portal")) assertNotNull(controller.request(action,body).block()); + for (String action:List.of("checkout", "portal")) assertNotNull(controller.request(action,body,request).block()); + } + } + @Test void publicCloudConfigurationRejectsCheckoutEvenWithForgedOrigins() { + for (String publicUrl : List.of("https://app.lowcoder.cloud", "https://APP.LOWCODER.CLOUD/", "https://app.lowcoder.cloud.:443/")) { + controller = mock(EnterpriseLicenseController.class,withSettings().mockMaker("mock-maker-subclass") + .useConstructor(sessions,config,capabilities,"https://flow.example/webhook/enterprise",publicUrl).defaultAnswer(CALLS_REAL_METHODS)); + ((com.fasterxml.jackson.databind.node.ObjectNode)body).put("returnOrigin", "https://self-hosted.example"); + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> controller.request("checkout",body,request).block()); + assertEquals(HttpStatus.FORBIDDEN, error.getStatusCode()); + verify(controller,never()).send(anyMap(),anyString()); + } + verifyNoInteractions(capabilities); + } + @Test void publicCloudRequestHostRejectsCheckoutWithoutPublicUrlConfiguration() { + controller = mock(EnterpriseLicenseController.class,withSettings().mockMaker("mock-maker-subclass") + .useConstructor(sessions,config,capabilities,"https://flow.example/webhook/enterprise","").defaultAnswer(CALLS_REAL_METHODS)); + ((com.fasterxml.jackson.databind.node.ObjectNode)body).put("returnOrigin", "https://self-hosted.example"); + ServerHttpRequest cloudRequest = MockServerHttpRequest.post("https://app.lowcoder.cloud/api/enterprise-licenses/checkout").build(); + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> controller.request("checkout",body,cloudRequest).block()); + assertEquals(HttpStatus.FORBIDDEN, error.getStatusCode()); + verify(controller,never()).send(anyMap(),anyString()); + verifyNoInteractions(capabilities); + } + @Test void publicCloudReturnOriginCannotStartCheckoutThroughAnInternalHost() { + ((com.fasterxml.jackson.databind.node.ObjectNode)body).put("returnOrigin", "https://app.lowcoder.cloud"); + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> controller.request("checkout",body,request).block()); + assertEquals(HttpStatus.FORBIDDEN, error.getStatusCode()); + verify(controller,never()).send(anyMap(),anyString()); + verifyNoInteractions(capabilities); + } + @Test void otherLowcoderHostsAndSimilarDomainsKeepCheckout() { + for (String origin : List.of("https://lowcoder.cloud", "https://staging.lowcoder.cloud", "https://private.app.lowcoder.cloud", + "https://app.lowcoder.cloud.example", "https://my-lowcoder.example")) { + controller = mock(EnterpriseLicenseController.class,withSettings().mockMaker("mock-maker-subclass") + .useConstructor(sessions,config,capabilities,"https://flow.example/webhook/enterprise",origin).defaultAnswer(CALLS_REAL_METHODS)); + doReturn(Mono.just(mapper.valueToTree(Map.of("success",true)))).when(controller).send(anyMap(),anyString()); + assertNotNull(controller.request("checkout",body,MockServerHttpRequest.post(origin + "/api/enterprise-licenses/checkout").build()).block()); + verify(controller).send(anyMap(),eq("private-test-token")); + } + } + @Test void existingCloudPurchasesRemainManageableWithoutNewCheckout() { + controller = mock(EnterpriseLicenseController.class,withSettings().mockMaker("mock-maker-subclass") + .useConstructor(sessions,config,capabilities,"https://flow.example/webhook/enterprise","https://app.lowcoder.cloud").defaultAnswer(CALLS_REAL_METHODS)); + doReturn(Mono.just(mapper.valueToTree(Map.of("success",true)))).when(controller).send(anyMap(),anyString()); + for (String action : List.of("status", "download", "portal")) { + assertNotNull(controller.request(action,body,request).block()); } } @Test void httpReturnExceptionDoesNotAllowRemoteHostsOrUrlTricks() { @@ -79,7 +129,7 @@ class EnterpriseLicenseControllerTest { "http://localhost@evil.test", "http://evil.test@localhost", "http://192.168.1.2:8000", "http://localhost:0", "http://localhost:65536", "http://localhost:8000/other", "http://localhost?next=elsewhere", "http://localhost#other", "file:///tmp")) { ((com.fasterxml.jackson.databind.node.ObjectNode)body).put("returnOrigin",origin); - assertThrows(ResponseStatusException.class,()->controller.request("checkout",body).block(),origin); + assertThrows(ResponseStatusException.class,()->controller.request("checkout",body,request).block(),origin); } verify(controller,never()).send(anyMap(),anyString()); }