diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 309dc8965a..a8276c4876 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -83,7 +83,9 @@ jobs:
tests:
name: Backend tests
runs-on: ubuntu-latest
- timeout-minutes: 45
+ # The plugin e2e modules alone need well over half an hour: every scheduler-based
+ # engine (presto, trino) starts its own container per documented version.
+ timeout-minutes: 90
steps:
- uses: actions/checkout@v4
@@ -97,8 +99,19 @@ jobs:
# The root pom has no Surefire pin; Maven's default (2.12.4) cannot run
# JUnit 5 and silently executes zero tests. Invoke Surefire explicitly
# until the root pom pins the plugin version.
+ #
+ # Install first (skipping test execution) so downstream modules resolve
+ # packaged artifacts: the shaded drivers only exist after the package
+ # phase, and a bare test-compile would leave their plugin tests with a
+ # driver-less classpath.
+ - name: Build and install all modules
+ run: >
+ ./mvnw -B clean install
+ -DskipTests -Dcheckstyle.skip -Dspotbugs.skip -Dgpg.skip
+ -Dskip.pnpm -Dskip.npm -Dskip.installnodenpm
+
- name: Run the JUnit 5 suite
run: >
- ./mvnw -B test-compile org.apache.maven.plugins:maven-surefire-plugin:3.2.2:test
+ ./mvnw -B org.apache.maven.plugins:maven-surefire-plugin:3.2.2:test
-Dcheckstyle.skip -Dspotbugs.skip -Dgpg.skip
-Dskip.pnpm -Dskip.npm -Dskip.installnodenpm
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7e52acaddc..79282ec7cc 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -109,46 +109,133 @@ jobs:
maven:
name: Publish to Maven Central
runs-on: ubuntu-latest
- timeout-minutes: 60
+ timeout-minutes: 150
needs: build
+ env:
+ # Publishing to Maven Central is permanent, so it runs last and only when
+ # its secrets exist; a half-configured repository still releases the rest.
+ CENTRAL_CONFIGURED: ${{ secrets.CENTRAL_USERNAME != '' && secrets.GPG_PRIVATE_KEY != '' }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
- # Imports the GPG key and injects the metaforge-ossrh server
- # credentials into the Maven settings. The gpg-passphrase input is the
- # NAME of the env variable holding the passphrase, provided below.
- - name: Set up JDK 21 with signing and repository credentials
+ # The distribution and the store archives (dist/plugins) are built once
+ # here; the publishing steps below reuse them, so a deploy that fails
+ # half-way leaves nothing behind and re-running finishes the release.
+ - name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- gpg-private-key: ${{ secrets.GPG_SECRET }}
- gpg-passphrase: GPG_PASSPHRASE
- server-id: metaforge-ossrh
- server-username: OSSRH_USERNAME
- server-password: OSSRH_TOKEN
-
- # Fails fast with a clear message when the key or passphrase is wrong,
- # instead of stalling or failing halfway through the deploy.
- - name: Verify the GPG signing key
+ - name: Build the distribution and the store archives
+ run: >
+ ./mvnw -B clean install
+ -Dmaven.test.skip=true -Dcheckstyle.skip -Dspotbugs.skip -Dgpg.skip
+ -Dskip.pnpm -Dskip.npm -Dskip.installnodenpm
+
+ - uses: actions/setup-java@v4
+ if: env.CENTRAL_CONFIGURED == 'true'
+ with:
+ distribution: temurin
+ java-version: 21
+ cache: maven
+ # Publishing goes to Maven Central through the Central Portal with the
+ # server id "central"; the gpg-passphrase input is the NAME of the env
+ # variable holding the passphrase, provided below.
+ server-id: central
+ server-username: CENTRAL_USERNAME
+ server-password: CENTRAL_PASSWORD
+ gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
+ gpg-passphrase: MAVEN_GPG_PASSPHRASE
+ - name: Publish the Maven artifacts to Maven Central
+ if: env.CENTRAL_CONFIGURED == 'true'
env:
- GPG_PASSPHRASE: ${{ secrets.GPG_PASSWORD }}
+ CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
+ CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
+ MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
- gpg --list-secret-keys --keyid-format long
- echo 'metaforge signing check' | gpg --batch --yes --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" --local-user "$(gpg --list-secret-keys --keyid-format long --with-colons | awk -F: '/^sec/{print $5; exit}')" -o /tmp/signing-check.sig --sign
- rm -f /tmp/signing-check.sig
+ version="$(./mvnw -B -Dexec.executable=echo -Dexec.args='${project.version}' --non-recursive \
+ -Dorg.slf4j.simpleLogger.defaultLogLevel=WARN exec:exec | tail -1)"
+ if curl --silent --fail --head --output /dev/null \
+ "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge/${version}/metaforge-${version}.pom"; then
+ echo "::notice::${version} is on Maven Central already"
+ exit 0
+ fi
+ ./mvnw --batch-mode --no-transfer-progress -Prelease,central -Dcentral.skip=false \
+ -Dmaven.test.skip=true -Dcheckstyle.skip -Dspotbugs.skip \
+ -Dskip.pnpm -Dskip.npm -Dskip.installnodenpm deploy \
+ -pl '!test/metaforge-test-condor,!test/metaforge-test-convert,!test/metaforge-test-core,!test/metaforge-test-lib,!test/metaforge-test-executor,!test/metaforge-test-scheduler,!test/metaforge-test-fs,!test/metaforge-test-driver,!test/metaforge-test-parser,!test/metaforge-test-plugin'
+ - name: Skip Maven Central
+ if: env.CENTRAL_CONFIGURED != 'true'
+ run: echo "::notice::Maven Central skipped; set CENTRAL_USERNAME, CENTRAL_PASSWORD, GPG_PRIVATE_KEY and GPG_PASSPHRASE to publish there"
+
+ # The store catalogue and the statistics catalog are built from the very
+ # archives just deployed to Maven Central (dist/plugins holds copies), so
+ # one SHA-256 and size are valid for the Maven and CDN sources alike.
+ - name: Build the store catalogue and the statistics catalog
+ run: >
+ node configure/publish/build-metadata.mjs --dist dist/plugins --strict
+ --out dist/metadata.json --catalog dist/release-catalog.json
+ --commit "$GITHUB_SHA" --generation "$GITHUB_RUN_NUMBER" --released now
+
+ - name: Upload the catalogues
+ uses: actions/upload-artifact@v4
+ with:
+ name: store-catalogue
+ if-no-files-found: error
+ path: |
+ dist/metadata.json
+ dist/release-catalog.json
- - name: Deploy to Maven Central
+ # Order: packages to the CDN -> catalog into ForgeStats -> public metadata.json.
+ # A failed step keeps the previous public catalogue, and re-running is safe.
+ - name: Publish the packages to the CDN
env:
- OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }}
- OSSRH_TOKEN: ${{ secrets.OSSRH_TOKEN }}
- GPG_PASSPHRASE: ${{ secrets.GPG_PASSWORD }}
- run: >
- ./mvnw -B clean deploy -Pdeploy
- -DskipTests -Dcheckstyle.skip -Dspotbugs.skip
- -Dskip.pnpm -Dskip.npm -Dskip.installnodenpm
- -Dgpg.passphrase="$GPG_PASSPHRASE"
- -pl '!test/metaforge-test-condor,!test/metaforge-test-convert,!test/metaforge-test-core,!test/metaforge-test-lib,!test/metaforge-test-executor,!test/metaforge-test-scheduler,!test/metaforge-test-fs,!test/metaforge-test-driver,!test/metaforge-test-parser,!test/metaforge-test-plugin'
+ OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
+ OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
+ OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
+ OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
+ run: |
+ if [ -z "$OSS_ACCESS_KEY_ID" ]; then
+ echo '::warning::OSS secrets are not set; skipping the CDN upload (the store falls back to Maven Central)'
+ exit 0
+ fi
+ # Official ossutil 1.x installer (same tool as configure/publish/publish-oss-artifact.sh).
+ curl -fsSL https://gosspublic.alicdn.com/ossutil/install.sh | sudo bash
+ oss() { ossutil -e "$OSS_ENDPOINT" -i "$OSS_ACCESS_KEY_ID" -k "$OSS_ACCESS_KEY_SECRET" "$@"; }
+ jq -r '.plugins[] | .sources.cdn' dist/release-catalog.json | while read -r path; do
+ file="dist/plugins/$(basename "$path")"
+ oss cp "$file" "oss://$OSS_BUCKET/applications/metaforge/plugins/$path" -f
+ done
+
+ - name: Import the statistics catalog into ForgeStats
+ env:
+ STATS_SSH_KEY: ${{ secrets.STATS_SSH_KEY }}
+ STATS_SSH_TARGET: ${{ secrets.STATS_SSH_TARGET }}
+ run: |
+ if [ -z "$STATS_SSH_KEY" ] || [ -z "$STATS_SSH_TARGET" ]; then
+ echo '::warning::ForgeStats secrets are not set; downloads of this release are not counted'
+ exit 0
+ fi
+ umask 077
+ printf '%s\n' "$STATS_SSH_KEY" > "$RUNNER_TEMP/stats-key"
+ digest=$(sha256sum dist/release-catalog.json | cut -d ' ' -f 1)
+ ssh -T -i "$RUNNER_TEMP/stats-key" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
+ "$STATS_SSH_TARGET" "sudo -n /usr/local/sbin/forgestats-admin catalog metaforge $digest" < dist/release-catalog.json
+ rm -f "$RUNNER_TEMP/stats-key"
+
+ - name: Publish the store catalogue
+ env:
+ OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
+ OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
+ OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
+ OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
+ run: |
+ if [ -z "$OSS_ACCESS_KEY_ID" ]; then
+ echo '::warning::OSS secrets are not set; publish dist/metadata.json from the store-catalogue artifact manually'
+ exit 0
+ fi
+ ossutil -e "$OSS_ENDPOINT" -i "$OSS_ACCESS_KEY_ID" -k "$OSS_ACCESS_KEY_SECRET" \
+ cp dist/metadata.json "oss://$OSS_BUCKET/applications/metaforge/metadata/metadata.json" -f
diff --git a/README.md b/README.md
index d4d1307594..e2a4ce84d3 100644
--- a/README.md
+++ b/README.md
@@ -4,16 +4,14 @@
# MetaForge
----
+**Integrated software for data transformation, integration, and visualization**
-
-[](https://github.com/EdurtIO/metaforge)
-
+---
[](https://opensource.org/licenses/Apache-2.0)
-
+
-
+[](https://hub.docker.com/r/devliveorg/metaforge)


@@ -23,28 +21,118 @@
-### What is metaforge?
+## What is MetaForge?
+
+---
+
+MetaForge is integrated software for data transformation, integration, and visualization. It supports a wide variety of data sources — relational databases, NoSQL databases, big data engines, message queues and file systems — and lets you manage multiple data sources, query and transform data, build datasets, and create charts and monitoring views.
+
+## Features
---
-MetaForge is integrated software for data transformation, integration, and visualization. Support a variety of data sources, file types, big data related database, relational database, NoSQL database, etc. Through the software can realize the management of multiple data sources, the data under the source of various operations conversion, making data charts, monitoring data sources and other functions.
+- **Data source management** — connect, test and manage 50+ data sources from a single console
+- **SQL query console** — IDE-style editor with syntax highlighting, formatting, execution history and export
+- **Datasets** — build reusable datasets from tables or ad-hoc queries, preview data and define column metadata
+- **Visualization** — drag-and-drop dashboards with tables, pies and more chart types
+- **Reports** — schedule and share reports
+- **Workflows** — chain data jobs into repeatable workflows
+- **File storage** — read and write files through Aliyun OSS, Amazon S3, HDFS, local storage and more
+- **Notifications** — DingTalk, email and other channels for job and system events
+- **AI assistant** — chat with your data through configured AI providers
+- **Plugin architecture** — every connector, converter and storage backend is a hot-loadable plugin
+- **Administration** — users, roles, menus and runtime configuration with fine-grained permissions
+
+## Requirements
-| Username | Password |
-|:---------:|:------------|
-| `metaforge` | `123456789` |
-| `admin` | `12345678` |
+---
+
+| Dependency | Version |
+|:-----------|:---------------------------------------------|
+| JDK | 21 |
+| Maven | 3.9+ (wrapper `./mvnw` included) |
+| Node.js | 20 |
+| pnpm | 9.13.2 |
-### Require
+## Getting Started
---
-> Must-read for users: Be sure to execute the following command after cloning the code locally
+> Must-read for users: be sure to execute the following command after cloning the code locally
```bash
cp configure/git-hook/* .git/hooks
chmod 700 .git/hooks/*
```
+### Run the backend (development)
+
+Run the main class `org.devlive.metaforge.server.MetaForge` (module `core/metaforge-server`) from your IDE.
+The server starts on [http://localhost:9096](http://localhost:9096). On first launch the setup wizard guides you through
+selecting the metadata database (embedded H2 by default, or MySQL / PostgreSQL / SQL Server / Oracle) and creating the
+administrator account.
+
+### Run the frontend (development)
+
+```bash
+pnpm install
+pnpm --filter metaforge-web dev
+```
+
+The web console starts on [http://localhost:3001](http://localhost:3001) and proxies API calls to the backend.
+
+### Build for production
+
+```bash
+# Frontend
+pnpm --filter metaforge-web build
+
+# Backend + distribution package (the server serves the built frontend
+# from core/metaforge-web/dist; the tarball lands in dist/)
+./mvnw -T1C clean package -DskipTests
+```
+
+## Docker
+
+---
+
+```bash
+docker pull devliveorg/metaforge:latest
+docker run -d --name metaforge -p 9096:9096 devliveorg/metaforge:latest
+```
+
+Or unpack the distribution built locally (`dist/metaforge-server-2026.0.0-bin.tar.gz`) and run `bin/startup.sh`.
+
+## Project Structure
+
+---
+
+```
+metaforge
+├── client # Command line client
+├── configure # Default configuration and plugin manifests
+├── core
+│ ├── metaforge-captcha # Captcha generation
+│ ├── metaforge-common # Shared utilities and enums
+│ ├── metaforge-condor # Scheduling integration
+│ ├── metaforge-server # Spring Boot server (REST API entry point)
+│ ├── metaforge-service # Domain services and persistence
+│ ├── metaforge-security # Security configuration
+│ ├── metaforge-sql # SQL statement builders
+│ ├── metaforge-spi # Plugin SPI contracts
+│ ├── metaforge-web # Vue 3 + Tailwind web console
+├── convert # Data converters (JSON, CSV, XML, ...)
+├── driver # Database drivers (Redis, MongoDB, ...)
+├── executor # Execution engines
+├── fs # File storage plugins (OSS, S3, local, ...)
+├── lib # Shared libraries (HTTP client, logging, shell)
+├── notify # Notification channels (DingTalk, ...)
+├── parser # SQL parsers (MySQL, Trino, ...)
+├── plugin # Data source plugins (50+ connectors)
+├── scheduler # Job scheduling
+└── shaded # Shaded third-party integrations
+```
+
## Supported Connectors
---
@@ -105,17 +193,11 @@ Here are some of the major database solutions that are supported:
-## System architecture
-
----
-
-
-
-## Stargazers over time
+## Documentation
---
-[](https://star-history.dera.page/#devlive-community/metaforge&Timeline)
+[Extended documentation for MetaForge](https://metaforge.devlive.org)
## Join Us
@@ -131,11 +213,11 @@ Please visit [Issues 950](https://github.com/devlive-community/metaforge/issues/
[](https://www.rainbond.com/)
[](https://vuejs.org/)
-## Installation and Configuration
+## Stargazers over time
---
-[Extended documentation for MetaForge](https://metaforge.devlive.org)
+[](https://star-history.dera.page/#devlive-community/metaforge&Timeline)
## Contributors
diff --git a/README.ru.md b/README.ru.md
index de30f8eca8..525539e4da 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -4,16 +4,14 @@
# MetaForge
----
+**Интегрированное программное обеспечение для преобразования, интеграции и визуализации данных**
-
-[](https://github.com/EdurtIO/metaforge)
-
+---
[](https://opensource.org/licenses/Apache-2.0)
-
+
-
+[](https://hub.docker.com/r/devliveorg/metaforge)


@@ -23,35 +21,124 @@
-### Что такое MetaForge?
+## Что такое MetaForge?
---
-MetaForge — это интегрированное программное обеспечение для преобразования, интеграции и визуализации данных. Поддержка нескольких источников данных, типов файлов, баз данных, связанных с большими данными, реляционных баз данных, баз данных NoSQL и многого другого. С помощью программного обеспечения может быть реализовано управление несколькими источниками данных, а также могут выполняться различные операции с данными в источнике, могут быть построены диаграммы данных, могут быть обнаружены источники данных и могут быть выполнены другие функции.
+MetaForge — это интегрированное программное обеспечение для преобразования, интеграции и визуализации данных. Оно поддерживает самые разные источники данных — реляционные СУБД, NoSQL-базы данных, big data движки, очереди сообщений и файловые системы — и позволяет управлять множеством источников из единой консоли, запрашивать и преобразовывать данные, строить наборы данных, диаграммы и представления мониторинга.
+
+## Возможности
+
+---
+
+- **Управление источниками данных** — подключение, проверка и управление 50+ источниками из единой консоли
+- **SQL-консоль** — редактор в стиле IDE с подсветкой синтаксиса, форматированием, историей выполнения и экспортом
+- **Наборы данных** — создание переиспользуемых наборов из таблиц или произвольных запросов, предпросмотр данных и метаданные столбцов
+- **Визуализация** — дашборды с перетаскиванием: таблицы, круговые диаграммы и другие типы графиков
+- **Отчёты** — планирование и совместный доступ к отчётам
+- **Рабочие процессы** — объединение задач обработки данных в повторяемые workflow
+- **Файловое хранилище** — чтение и запись файлов через Aliyun OSS, Amazon S3, HDFS, локальное хранилище и другие
+- **Уведомления** — DingTalk, электронная почта и другие каналы для событий задач и системы
+- **AI-ассистент** — общайтесь со своими данными через настроенных AI-провайдеров
+- **Плагинная архитектура** — каждый коннектор, конвертер и хранилище являются подключаемыми плагинами
+- **Администрирование** — пользователи, роли, меню и конфигурация среды выполнения с тонкой настройкой прав
+
+## Требования
+
+---
-| Имя пользователя | пароль |
-|:----------------:|:------------|
-| `metaforge` | `123456789` |
-| `admin` | `12345678` |
+| Зависимость | Версия |
+|:------------|:----------------------------------|
+| JDK | 21 |
+| Maven | 3.9+ (в комплекте `./mvnw`) |
+| Node.js | 20 |
+| pnpm | 9.13.2 |
-### просьба
+## Быстрый старт
---
-> Обязательный к прочтению пользователь: обязательно клонируйте код локально и выполните следующую команду
+> Обязательно к прочтению: после клонирования кода обязательно выполните следующие команды
```bash
cp configure/git-hook/* .git/hooks
chmod 700 .git/hooks/*
```
+### Запуск бэкенда (разработка)
+
+Запустите главный класс `org.devlive.metaforge.server.MetaForge` (модуль `core/metaforge-server`) в своей IDE.
+Сервер доступен на [http://localhost:9096](http://localhost:9096). При первом запуске мастер установки поможет выбрать
+базу метаданных (по умолчанию встроенная H2, также MySQL / PostgreSQL / SQL Server / Oracle) и создать
+учётную запись администратора.
+
+### Запуск фронтенда (разработка)
+
+```bash
+pnpm install
+pnpm --filter metaforge-web dev
+```
+
+Веб-консоль доступна на [http://localhost:3001](http://localhost:3001) и проксирует API-запросы к бэкенду.
+
+### Сборка для продакшена
+
+```bash
+# Фронтенд
+pnpm --filter metaforge-web build
+
+# Бэкенд + дистрибутив (сервер отдаёт собранный фронтенд
+# из core/metaforge-web/dist; архив попадает в dist/)
+./mvnw -T1C clean package -DskipTests
+```
+
+## Docker
+
+---
+
+```bash
+docker pull devliveorg/metaforge:latest
+docker run -d --name metaforge -p 9096:9096 devliveorg/metaforge:latest
+```
+
+Либо распакуйте собранный локально дистрибутив (`dist/metaforge-server-2026.0.0-bin.tar.gz`) и запустите `bin/startup.sh`.
+
+## Структура проекта
+
+---
+
+```
+metaforge
+├── client # Консольный клиент
+├── configure # Конфигурация по умолчанию и манифесты плагинов
+├── core
+│ ├── metaforge-captcha # Генерация капчи
+│ ├── metaforge-common # Общие утилиты и перечисления
+│ ├── metaforge-condor # Интеграция планировщика
+│ ├── metaforge-server # Сервер Spring Boot (точка входа REST API)
+│ ├── metaforge-service # Доменные сервисы и хранение данных
+│ ├── metaforge-security # Конфигурация безопасности
+│ ├── metaforge-sql # Конструкторы SQL-запросов
+│ ├── metaforge-spi # Контракты plugin SPI
+│ ├── metaforge-web # Веб-консоль Vue 3 + Tailwind
+├── convert # Конвертеры данных (JSON, CSV, XML, ...)
+├── driver # Драйверы баз данных (Redis, MongoDB, ...)
+├── executor # Движки выполнения
+├── fs # Плагины файловых хранилищ (OSS, S3, локальное, ...)
+├── lib # Общие библиотеки (HTTP-клиент, логирование, shell)
+├── notify # Каналы уведомлений (DingTalk, ...)
+├── parser # SQL-парсеры (MySQL, Trino, ...)
+├── plugin # Плагины источников данных (50+ коннекторов)
+└── shaded # Shaded сторонние интеграции
+```
+
## Поддерживаемые коннекторы
---
-MetaForge может запрашивать данные из любого хранилища данных или движка данных (ClickHouse, MySQL, Presto и т.д.), использующего SQL.
+MetaForge может запрашивать данные из любого SQL-хранилища или движка данных (ClickHouse, MySQL, Presto и др.).
-Ниже приведены некоторые из основных поддерживаемых решений для баз данных.
+Ниже представлены некоторые из основных поддерживаемых решений для баз данных:
@@ -105,25 +192,19 @@ MetaForge может запрашивать данные из любого хр
-## Архитектура системы
+## Документация
---
-
-
-## Атлас вкладов
-
----
-
-[](https://star-history.dera.page/#devlive-community/metaforge&Timeline)
+[Расширенная документация по MetaForge](https://metaforge.devlive.org)
## Присоединяйтесь к нам
---
-Вид [Issues 950](https://github.com/devlive-community/metaforge/issues/950)
+Посетите [Issues 950](https://github.com/devlive-community/metaforge/issues/950)
-## Признание
+## Благодарности
---
@@ -131,13 +212,13 @@ MetaForge может запрашивать данные из любого хр
[](https://www.rainbond.com/)
[](https://vuejs.org/)
-## Установка и настройка
+## История звёзд
---
-[Расширенная документация по MetaForge](https://metaforge.devlive.org)
+[](https://star-history.dera.page/#devlive-community/metaforge&Timeline)
-## Участников
+## Участники
---
diff --git a/README.zh_CN.md b/README.zh_CN.md
index 9f4c79574b..993dffe945 100644
--- a/README.zh_CN.md
+++ b/README.zh_CN.md
@@ -4,16 +4,14 @@
# MetaForge
----
+**用于数据转换、集成和可视化的集成软件**
-
-[](https://github.com/EdurtIO/metaforge)
-
+---
[](https://opensource.org/licenses/Apache-2.0)
-
+
-
+[](https://hub.docker.com/r/devliveorg/metaforge)


@@ -23,18 +21,40 @@
-### 什么是 metaforge?
+## 什么是 MetaForge?
---
-MetaForge 是用于数据转换、集成和可视化的集成软件。支持多种数据源、文件类型、大数据相关数据库、关系数据库、NoSQL数据库等。通过软件可以实现多数据源的管理,对源下的数据进行各种操作转换、制作数据图表、监控数据源和其他功能。
+MetaForge 是用于数据转换、集成和可视化的集成软件。支持多种数据源 —— 关系型数据库、NoSQL 数据库、大数据引擎、消息队列和文件系统 —— 让您在一个控制台中管理多个数据源,查询和转换数据、构建数据集,并创建图表和监控视图。
+
+## 功能特性
+
+---
+
+- **数据源管理** — 在单一控制台中连接、测试和管理 50+ 数据源
+- **SQL 查询控制台** — IDE 风格的编辑器,支持语法高亮、格式化、执行历史和导出
+- **数据集** — 基于表或临时查询构建可复用的数据集,预览数据并定义列元数据
+- **可视化** — 拖拽式仪表盘,支持表格、饼图等多种图表类型
+- **报表** — 定时调度并分享报表
+- **工作流** — 将数据任务串联为可重复执行的工作流
+- **文件存储** — 通过阿里云 OSS、Amazon S3、HDFS、本地存储等读写文件
+- **通知** — 钉钉、邮件等渠道的任务与系统事件通知
+- **AI 助手** — 通过配置的 AI 提供商与您的数据对话
+- **插件架构** — 每个连接器、转换器和存储后端都是可热加载的插件
+- **系统管理** — 用户、角色、菜单与运行时配置,支持细粒度权限
+
+## 环境要求
+
+---
-| 用户名 | 密码 |
-|:---------:|:------------|
-| `metaforge` | `123456789` |
-| `admin` | `12345678` |
+| 依赖 | 版本 |
+|:---------|:------------------------------|
+| JDK | 21 |
+| Maven | 3.9+(已内置 `./mvnw`) |
+| Node.js | 20 |
+| pnpm | 9.13.2 |
-### 要求
+## 快速开始
---
@@ -45,6 +65,72 @@ cp configure/git-hook/* .git/hooks
chmod 700 .git/hooks/*
```
+### 运行后端(开发)
+
+在 IDE 中运行主类 `org.devlive.metaforge.server.MetaForge`(模块 `core/metaforge-server`)。
+服务启动后访问 [http://localhost:9096](http://localhost:9096)。首次启动时,安装向导会引导您选择元数据库
+(默认内嵌 H2,也可选择 MySQL / PostgreSQL / SQL Server / Oracle)并创建管理员账号。
+
+### 运行前端(开发)
+
+```bash
+pnpm install
+pnpm --filter metaforge-web dev
+```
+
+Web 控制台启动在 [http://localhost:3001](http://localhost:3001),API 请求会代理到后端。
+
+### 生产构建
+
+```bash
+# 前端
+pnpm --filter metaforge-web build
+
+# 后端 + 发行包(服务端会直接加载 core/metaforge-web/dist
+# 下构建好的前端;压缩包输出到 dist/)
+./mvnw -T1C clean package -DskipTests
+```
+
+## Docker
+
+---
+
+```bash
+docker pull devliveorg/metaforge:latest
+docker run -d --name metaforge -p 9096:9096 devliveorg/metaforge:latest
+```
+
+或者解压本地构建的发行包(`dist/metaforge-server-2026.0.0-bin.tar.gz`),运行 `bin/startup.sh`。
+
+## 项目结构
+
+---
+
+```
+metaforge
+├── client # 命令行客户端
+├── configure # 默认配置与插件清单
+├── core
+│ ├── metaforge-captcha # 验证码生成
+│ ├── metaforge-common # 通用工具与枚举
+│ ├── metaforge-condor # 调度集成
+│ ├── metaforge-server # Spring Boot 服务端(REST API 入口)
+│ ├── metaforge-service # 领域服务与持久化
+│ ├── metaforge-security # 安全配置
+│ ├── metaforge-sql # SQL 语句构建器
+│ ├── metaforge-spi # 插件 SPI 契约
+│ ├── metaforge-web # Vue 3 + Tailwind Web 控制台
+├── convert # 数据转换器(JSON、CSV、XML 等)
+├── driver # 数据库驱动(Redis、MongoDB 等)
+├── executor # 执行引擎
+├── fs # 文件存储插件(OSS、S3、本地存储等)
+├── lib # 共享库(HTTP 客户端、日志、Shell)
+├── notify # 通知渠道(钉钉等)
+├── parser # SQL 解析器(MySQL、Trino 等)
+├── plugin # 数据源插件(50+ 连接器)
+└── shaded # 第三方集成 Shade 封装
+```
+
## 支持的连接器
---
@@ -105,17 +191,11 @@ MetaForge 可以从任何使用 SQL 的数据存储或数据引擎(ClickHouse
-## 系统架构
+## 文档
---
-
-
-## 贡献图谱
-
----
-
-[](https://star-history.dera.page/#devlive-community/metaforge&Timeline)
+[MetaForge 的扩展文档](https://metaforge.devlive.org)
## 加入我们
@@ -131,11 +211,11 @@ MetaForge 可以从任何使用 SQL 的数据存储或数据引擎(ClickHouse
[](https://www.rainbond.com/)
[](https://vuejs.org/)
-## 安装和配置
+## Star 历史
---
-[MetaForge 的扩展文档](https://metaforge.devlive.org)
+[](https://star-history.dera.page/#devlive-community/metaforge&Timeline)
## 贡献者
diff --git a/configure/docker/application.properties b/configure/docker/application.properties
index f4e51ed0a4..063f19b4e5 100644
--- a/configure/docker/application.properties
+++ b/configure/docker/application.properties
@@ -96,6 +96,7 @@ metaforge.parser.sql.defaultEngine=Trino
################################# File System configure #################################
# For the file storage type, please refer to metaforge-fs-, which defaults to Local
+# These are the defaults: an administrator sets file storage in System settings, which then takes precedence.
# ------ Local File System ------ #
metaforge.fs.type=LocalFs
metaforge.fs.access=
diff --git a/configure/etc/conf/application.properties b/configure/etc/conf/application.properties
index fba5a1c21d..f25c684357 100644
--- a/configure/etc/conf/application.properties
+++ b/configure/etc/conf/application.properties
@@ -26,6 +26,8 @@ spring.web.resources.add-mappings=true
spring.datasource.url=jdbc:mysql://localhost:3306/metaforge?useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true&useSSL=false&useOldAliasMetadataBehavior=true&jdbcCompliantTruncation=false&allowPublicKeyRetrieval=true
spring.datasource.username=root
spring.datasource.password=12345678
+# The metadata schema is migrated by MetaForge itself (DatabaseMigrator), never by Boot.
+spring.liquibase.enabled=false
################################ Cache configure #################################
# Set redis environment
@@ -96,6 +98,7 @@ metaforge.parser.sql.defaultEngine=Trino
################################# File System configure #################################
# For the file storage type, please refer to metaforge-fs-, which defaults to Local
+# These are the defaults: an administrator sets file storage in System settings, which then takes precedence.
# ------ Local File System ------ #
metaforge.fs.type=LocalFs
metaforge.fs.access=
@@ -145,6 +148,14 @@ plugin.manager.extend.packages=com.fasterxml.jackson
# 插件加载线程数:0 根据 CPU 自动选择(最多 4),1 串行;重启后生效
# Plugin loading workers: 0 = automatic (up to 4 CPUs), 1 = sequential; restart required
plugin.manager.load-threads=0
+# 插件商店:经 ForgeStats 统计服务下载并统计下载量;留空 stats-url 则直接下载、不计数
+# Plugin store: downloads go through the ForgeStats statistics service; an empty stats-url downloads directly without counting
+plugin.store.stats-url=https://stats.devlive.org
+plugin.store.app=metaforge
+# 下载来源:maven(Maven Central)、cdn(MetaForge CDN)或 mirror(自建镜像,目录结构同 CDN:{version}/{type}/{key}-bin.tar.gz)
+# Download source: maven (Maven Central), cdn (MetaForge CDN) or mirror (self-hosted, CDN layout {version}/{type}/{key}-bin.tar.gz)
+plugin.store.source=maven
+plugin.store.mirror=
################################# Experimental features #################################
# This configuration is used to dynamically increase the total number of rows of returned data in SQL during query, and currently only takes effect for user-directed queries
diff --git a/configure/etc/conf/executor/category.yaml b/configure/etc/conf/executor/category.yaml
deleted file mode 100644
index 48984be673..0000000000
--- a/configure/etc/conf/executor/category.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-- label: Input
- value: source
- supportExecutors:
- - Seatunnel
-- label: Transform
- value: transform
- supportExecutors:
- - Seatunnel
-- label: Output
- value: sink
- supportExecutors:
- - Seatunnel
diff --git a/configure/etc/conf/executor/sql-transform.yaml b/configure/etc/conf/executor/sql-transform.yaml
deleted file mode 100644
index d8afe66940..0000000000
--- a/configure/etc/conf/executor/sql-transform.yaml
+++ /dev/null
@@ -1,36 +0,0 @@
-id: sql-transform
-label: Sql
-tid: sql-transform
-key: Sql
-description: SQL transform
-category: transform
-configure:
- - field: source_table_name
- label: Source table name
- type: input
- placeholder: Please input source table name
- required: true
- description: The source table name, the query SQL table name must match this field.
- - field: result_table_name
- label: Result table name
- type: input
- placeholder: Please input result table name
- required: true
- - field: query
- label: Query
- type: textarea
- placeholder: Please input query
- required: true
- description: |
- The query SQL, it's a simple SQL supported base function and criteria filter operation. But the complex SQL unsupported yet, include: multi source table/rows JOIN and AGGREGATE operation and the like.
-ports:
- - id: input
- label: Input
- type: input
- required: true
- message: Input is required
- - id: output
- label: Output
- type: output
- required: true
- message: Output is required
diff --git a/configure/etc/conf/jvm.conf b/configure/etc/conf/jvm.conf
index b3fc38b233..d42c5111e9 100644
--- a/configure/etc/conf/jvm.conf
+++ b/configure/etc/conf/jvm.conf
@@ -1,3 +1,4 @@
-server
-Xmx2048m
-Xms1024m
+--add-opens=java.base/java.nio=ALL-UNNAMED
diff --git a/configure/etc/conf/plugin.conf b/configure/etc/conf/plugin.conf
index 7ca6327e65..8fdf666e6c 100644
--- a/configure/etc/conf/plugin.conf
+++ b/configure/etc/conf/plugin.conf
@@ -70,7 +70,7 @@ metaforge-fs-amazon-s3
metaforge-fs-minio
-- Executor list --
-metaforge-executor-local
+metaforge-executor-metaforge
metaforge-executor-seatunnel
-- Convert list --
diff --git a/configure/etc/conf/plugins.properties b/configure/etc/conf/plugins.properties
index 6a51497434..73bf0c04ad 100644
--- a/configure/etc/conf/plugins.properties
+++ b/configure/etc/conf/plugins.properties
@@ -54,7 +54,7 @@ metaforge-convert-json=convert/metaforge-convert-json/pom.xml
metaforge-convert-xml=convert/metaforge-convert-xml/pom.xml
metaforge-convert-csv=convert/metaforge-convert-csv/pom.xml
metaforge-convert-none=convert/metaforge-convert-none/pom.xml
-metaforge-executor-local=executor/metaforge-executor-local/pom.xml
+metaforge-executor-metaforge=executor/metaforge-executor-metaforge/pom.xml
metaforge-executor-seatunnel=executor/metaforge-executor-seatunnel/pom.xml
metaforge-scheduler-local=scheduler/metaforge-scheduler-local/pom.xml
metaforge-fs-local=fs/metaforge-fs-local/pom.xml
diff --git a/configure/metadata.json b/configure/metadata.json
index 14ae91de6a..685a071496 100644
--- a/configure/metadata.json
+++ b/configure/metadata.json
@@ -3,6 +3,7 @@
"label": "common.plugin.list.name",
"description": "common.plugin.list.description",
"i18nFormat": true,
+ "version": "2026.0.0",
"children": [
{
"key": "metaforge-plugin-mysql",
@@ -11,14 +12,31 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/mysql.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"8.0",
"5.7"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-mysql/2026.0.0/metaforge-plugin-mysql-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "schema",
+ "ddl",
+ "index",
+ "foreignKey",
+ "trigger",
+ "dataEdit",
+ "export",
+ "erDiagram"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-mysql/2026.0.0/metaforge-plugin-mysql-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-mysql/2026.0.0/metaforge-plugin-mysql-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-mysql-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-clickhouse",
@@ -27,13 +45,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/clickhouse.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-clickhouse/2026.0.0/metaforge-plugin-clickhouse-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-clickhouse/2026.0.0/metaforge-plugin-clickhouse-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-clickhouse/2026.0.0/metaforge-plugin-clickhouse-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-clickhouse-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-influxdb",
@@ -42,13 +71,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/influxdb.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-influxdb/2026.0.0/metaforge-plugin-influxdb-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-influxdb/2026.0.0/metaforge-plugin-influxdb-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-influxdb/2026.0.0/metaforge-plugin-influxdb-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-influxdb-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-solr",
@@ -57,13 +97,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/solr.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-solr/2026.0.0/metaforge-plugin-solr-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-solr/2026.0.0/metaforge-plugin-solr-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-solr/2026.0.0/metaforge-plugin-solr-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-solr-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-cratedb",
@@ -72,13 +123,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/cratedb.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-cratedb/2026.0.0/metaforge-plugin-cratedb-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-cratedb/2026.0.0/metaforge-plugin-cratedb-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-cratedb/2026.0.0/metaforge-plugin-cratedb-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-cratedb-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-zookeeper",
@@ -87,13 +149,22 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/zookeeper.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-zookeeper/2026.0.0/metaforge-plugin-zookeeper-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-zookeeper/2026.0.0/metaforge-plugin-zookeeper-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-zookeeper/2026.0.0/metaforge-plugin-zookeeper-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-zookeeper-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-kafka",
@@ -102,13 +173,22 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/kafka.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-kafka/2026.0.0/metaforge-plugin-kafka-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-kafka/2026.0.0/metaforge-plugin-kafka-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-kafka/2026.0.0/metaforge-plugin-kafka-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-kafka-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-h2",
@@ -117,13 +197,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/h2.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-h2/2026.0.0/metaforge-plugin-h2-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-h2/2026.0.0/metaforge-plugin-h2-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-h2/2026.0.0/metaforge-plugin-h2-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-h2-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-db2",
@@ -132,13 +223,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/db2.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-db2/2026.0.0/metaforge-plugin-db2-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-db2/2026.0.0/metaforge-plugin-db2-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-db2/2026.0.0/metaforge-plugin-db2-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-db2-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-dm",
@@ -147,13 +249,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/dm.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-dm/2026.0.0/metaforge-plugin-dm-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-dm/2026.0.0/metaforge-plugin-dm-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-dm/2026.0.0/metaforge-plugin-dm-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-dm-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-hdfs",
@@ -162,13 +275,22 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/hdfs.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-hdfs/2026.0.0/metaforge-plugin-hdfs-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-hdfs/2026.0.0/metaforge-plugin-hdfs-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-hdfs/2026.0.0/metaforge-plugin-hdfs-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-hdfs-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-alioss",
@@ -177,13 +299,22 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/alioss.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-alioss/2026.0.0/metaforge-plugin-alioss-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-alioss/2026.0.0/metaforge-plugin-alioss-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-alioss/2026.0.0/metaforge-plugin-alioss-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-alioss-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-dremio",
@@ -192,13 +323,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/dremio.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-dremio/2026.0.0/metaforge-plugin-dremio-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-dremio/2026.0.0/metaforge-plugin-dremio-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-dremio/2026.0.0/metaforge-plugin-dremio-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-dremio-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-druid",
@@ -207,13 +349,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/druid.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-druid/2026.0.0/metaforge-plugin-druid-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-druid/2026.0.0/metaforge-plugin-druid-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-druid/2026.0.0/metaforge-plugin-druid-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-druid-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-duckdb",
@@ -222,13 +375,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/duckdb.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-duckdb/2026.0.0/metaforge-plugin-duckdb-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-duckdb/2026.0.0/metaforge-plugin-duckdb-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-duckdb/2026.0.0/metaforge-plugin-duckdb-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-duckdb-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-pinot",
@@ -237,13 +401,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/pinot.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-pinot/2026.0.0/metaforge-plugin-pinot-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-pinot/2026.0.0/metaforge-plugin-pinot-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-pinot/2026.0.0/metaforge-plugin-pinot-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-pinot-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-cassandra",
@@ -252,13 +427,22 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/cassandra.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-cassandra/2026.0.0/metaforge-plugin-cassandra-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-cassandra/2026.0.0/metaforge-plugin-cassandra-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-cassandra/2026.0.0/metaforge-plugin-cassandra-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-cassandra-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-matrixone",
@@ -267,13 +451,30 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/matrixone.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-matrixone/2026.0.0/metaforge-plugin-matrixone-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "schema",
+ "ddl",
+ "index",
+ "foreignKey",
+ "trigger",
+ "dataEdit",
+ "export",
+ "erDiagram"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-matrixone/2026.0.0/metaforge-plugin-matrixone-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-matrixone/2026.0.0/metaforge-plugin-matrixone-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-matrixone-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-scylladb",
@@ -282,13 +483,22 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/scylladb.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-scylladb/2026.0.0/metaforge-plugin-scylladb-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-scylladb/2026.0.0/metaforge-plugin-scylladb-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-scylladb/2026.0.0/metaforge-plugin-scylladb-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-scylladb-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-paradedb",
@@ -297,13 +507,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/paradedb.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-paradedb/2026.0.0/metaforge-plugin-paradedb-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-paradedb/2026.0.0/metaforge-plugin-paradedb-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-paradedb/2026.0.0/metaforge-plugin-paradedb-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-paradedb-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-hive-2x",
@@ -312,13 +533,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/hive2x.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"2.x"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-hive-2x/2026.0.0/metaforge-plugin-hive-2x-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-hive-2x/2026.0.0/metaforge-plugin-hive-2x-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-hive-2x/2026.0.0/metaforge-plugin-hive-2x-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-hive-2x-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-kyuubi",
@@ -327,13 +559,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/kyuubi.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-kyuubi/2026.0.0/metaforge-plugin-kyuubi-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-kyuubi/2026.0.0/metaforge-plugin-kyuubi-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-kyuubi/2026.0.0/metaforge-plugin-kyuubi-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-kyuubi-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-ignite",
@@ -342,13 +585,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/ignite.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-ignite/2026.0.0/metaforge-plugin-ignite-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-ignite/2026.0.0/metaforge-plugin-ignite-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-ignite/2026.0.0/metaforge-plugin-ignite-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-ignite-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-impala",
@@ -357,13 +611,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/impala.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-impala/2026.0.0/metaforge-plugin-impala-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-impala/2026.0.0/metaforge-plugin-impala-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-impala/2026.0.0/metaforge-plugin-impala-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-impala-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-kylin",
@@ -372,13 +637,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/kylin.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-kylin/2026.0.0/metaforge-plugin-kylin-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-kylin/2026.0.0/metaforge-plugin-kylin-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-kylin/2026.0.0/metaforge-plugin-kylin-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-kylin-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-timescale",
@@ -387,13 +663,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/timescale.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-timescale/2026.0.0/metaforge-plugin-timescale-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-timescale/2026.0.0/metaforge-plugin-timescale-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-timescale/2026.0.0/metaforge-plugin-timescale-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-timescale-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-iotdb",
@@ -402,13 +689,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/iotdb.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-iotdb/2026.0.0/metaforge-plugin-iotdb-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-iotdb/2026.0.0/metaforge-plugin-iotdb-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-iotdb/2026.0.0/metaforge-plugin-iotdb-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-iotdb-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-monetdb",
@@ -417,13 +715,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/monetdb.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-monetdb/2026.0.0/metaforge-plugin-monetdb-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-monetdb/2026.0.0/metaforge-plugin-monetdb-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-monetdb/2026.0.0/metaforge-plugin-monetdb-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-monetdb-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-ydb",
@@ -432,13 +741,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/ydb.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-ydb/2026.0.0/metaforge-plugin-ydb-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-ydb/2026.0.0/metaforge-plugin-ydb-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-ydb/2026.0.0/metaforge-plugin-ydb-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-ydb-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-neo4j",
@@ -447,13 +767,22 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/neo4j.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-neo4j/2026.0.0/metaforge-plugin-neo4j-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-neo4j/2026.0.0/metaforge-plugin-neo4j-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-neo4j/2026.0.0/metaforge-plugin-neo4j-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-neo4j-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-oceanbase",
@@ -462,13 +791,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/oceanbase.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-oceanbase/2026.0.0/metaforge-plugin-oceanbase-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-oceanbase/2026.0.0/metaforge-plugin-oceanbase-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-oceanbase/2026.0.0/metaforge-plugin-oceanbase-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-oceanbase-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-oracle",
@@ -477,13 +817,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/oracle.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-oracle/2026.0.0/metaforge-plugin-oracle-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-oracle/2026.0.0/metaforge-plugin-oracle-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-oracle/2026.0.0/metaforge-plugin-oracle-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-oracle-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-phoenix",
@@ -492,13 +843,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/phoenix.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-phoenix/2026.0.0/metaforge-plugin-phoenix-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-phoenix/2026.0.0/metaforge-plugin-phoenix-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-phoenix/2026.0.0/metaforge-plugin-phoenix-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-phoenix-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-postgresql",
@@ -507,13 +869,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/postgresql.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-postgresql/2026.0.0/metaforge-plugin-postgresql-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-postgresql/2026.0.0/metaforge-plugin-postgresql-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-postgresql/2026.0.0/metaforge-plugin-postgresql-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-postgresql-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-presto",
@@ -522,13 +895,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/presto.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-presto/2026.0.0/metaforge-plugin-presto-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-presto/2026.0.0/metaforge-plugin-presto-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-presto/2026.0.0/metaforge-plugin-presto-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-presto-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-snowflake",
@@ -537,13 +921,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/snowflake.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-snowflake/2026.0.0/metaforge-plugin-snowflake-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-snowflake/2026.0.0/metaforge-plugin-snowflake-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-snowflake/2026.0.0/metaforge-plugin-snowflake-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-snowflake-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-sqlserver",
@@ -552,13 +947,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/sqlserver.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-sqlserver/2026.0.0/metaforge-plugin-sqlserver-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-sqlserver/2026.0.0/metaforge-plugin-sqlserver-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-sqlserver/2026.0.0/metaforge-plugin-sqlserver-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-sqlserver-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-tdengine",
@@ -567,13 +973,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/tdengine.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-tdengine/2026.0.0/metaforge-plugin-tdengine-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-tdengine/2026.0.0/metaforge-plugin-tdengine-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-tdengine/2026.0.0/metaforge-plugin-tdengine-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-tdengine-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-trino",
@@ -582,13 +999,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/trino.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-trino/2026.0.0/metaforge-plugin-trino-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-trino/2026.0.0/metaforge-plugin-trino-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-trino/2026.0.0/metaforge-plugin-trino-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-trino-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-doris",
@@ -597,13 +1025,30 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/doris.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-doris/2026.0.0/metaforge-plugin-doris-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "schema",
+ "ddl",
+ "index",
+ "foreignKey",
+ "trigger",
+ "dataEdit",
+ "export",
+ "erDiagram"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-doris/2026.0.0/metaforge-plugin-doris-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-doris/2026.0.0/metaforge-plugin-doris-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-doris-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-starrocks",
@@ -612,13 +1057,30 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/starrocks.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-starrocks/2026.0.0/metaforge-plugin-starrocks-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "schema",
+ "ddl",
+ "index",
+ "foreignKey",
+ "trigger",
+ "dataEdit",
+ "export",
+ "erDiagram"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-starrocks/2026.0.0/metaforge-plugin-starrocks-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-starrocks/2026.0.0/metaforge-plugin-starrocks-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-starrocks-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-hologres",
@@ -627,13 +1089,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/hologres.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-hologres/2026.0.0/metaforge-plugin-hologres-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-hologres/2026.0.0/metaforge-plugin-hologres-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-hologres/2026.0.0/metaforge-plugin-hologres-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-hologres-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-greptimedb",
@@ -642,13 +1115,22 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/greptimedb.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-greptimedb/2026.0.0/metaforge-plugin-greptimedb-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-greptimedb/2026.0.0/metaforge-plugin-greptimedb-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-greptimedb/2026.0.0/metaforge-plugin-greptimedb-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-greptimedb-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-questdb",
@@ -657,13 +1139,22 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/questdb.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-questdb/2026.0.0/metaforge-plugin-questdb-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-questdb/2026.0.0/metaforge-plugin-questdb-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-questdb/2026.0.0/metaforge-plugin-questdb-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-questdb-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-h2-tcp",
@@ -672,13 +1163,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/h2tcp.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-h2-tcp/2026.0.0/metaforge-plugin-h2-tcp-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-h2-tcp/2026.0.0/metaforge-plugin-h2-tcp-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-h2-tcp/2026.0.0/metaforge-plugin-h2-tcp-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-h2-tcp-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-ceresdb",
@@ -687,13 +1189,22 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/ceresdb.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-ceresdb/2026.0.0/metaforge-plugin-ceresdb-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-ceresdb/2026.0.0/metaforge-plugin-ceresdb-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-ceresdb/2026.0.0/metaforge-plugin-ceresdb-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-ceresdb-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-redis",
@@ -702,13 +1213,22 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/redis.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-redis/2026.0.0/metaforge-plugin-redis-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-redis/2026.0.0/metaforge-plugin-redis-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-redis/2026.0.0/metaforge-plugin-redis-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-redis-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-elasticsearch-8x",
@@ -717,13 +1237,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/elasticsearch8x.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"8.x"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-elasticsearch-8x/2026.0.0/metaforge-plugin-elasticsearch-8x-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-elasticsearch-8x/2026.0.0/metaforge-plugin-elasticsearch-8x-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-elasticsearch-8x/2026.0.0/metaforge-plugin-elasticsearch-8x-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-elasticsearch-8x-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-mongo-atlas",
@@ -732,13 +1263,20 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/mongoatlas.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-mongo-atlas/2026.0.0/metaforge-plugin-mongo-atlas-2026.0.0-bin.tar.gz"
+ "features": [
+ "query"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-mongo-atlas/2026.0.0/metaforge-plugin-mongo-atlas-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-mongo-atlas/2026.0.0/metaforge-plugin-mongo-atlas-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-mongo-atlas-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-mongo-community",
@@ -747,13 +1285,22 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/mongocommunity.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-mongo-community/2026.0.0/metaforge-plugin-mongo-community-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-mongo-community/2026.0.0/metaforge-plugin-mongo-community-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-mongo-community/2026.0.0/metaforge-plugin-mongo-community-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-mongo-community-bin.tar.gz"
+ }
},
{
"key": "metaforge-plugin-dolphindb",
@@ -762,13 +1309,24 @@
"i18nFormat": true,
"type": "Connector",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/plugin/dolphindb.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-dolphindb/2026.0.0/metaforge-plugin-dolphindb-2026.0.0-bin.tar.gz"
+ "features": [
+ "query",
+ "database",
+ "browse",
+ "index",
+ "foreignKey"
+ ],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-dolphindb/2026.0.0/metaforge-plugin-dolphindb-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-plugin-dolphindb/2026.0.0/metaforge-plugin-dolphindb-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-plugin-dolphindb-bin.tar.gz"
+ }
},
{
"key": "metaforge-convert-csv",
@@ -777,13 +1335,18 @@
"i18nFormat": true,
"type": "Converter",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/convert/csv.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-csv/2026.0.0/metaforge-convert-csv-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-csv/2026.0.0/metaforge-convert-csv-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-csv/2026.0.0/metaforge-convert-csv-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/convert/metaforge-convert-csv-bin.tar.gz"
+ }
},
{
"key": "metaforge-convert-txt",
@@ -792,13 +1355,18 @@
"i18nFormat": true,
"type": "Converter",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/convert/txt.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-txt/2026.0.0/metaforge-convert-txt-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-txt/2026.0.0/metaforge-convert-txt-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-txt/2026.0.0/metaforge-convert-txt-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/convert/metaforge-convert-txt-bin.tar.gz"
+ }
},
{
"key": "metaforge-convert-json",
@@ -807,13 +1375,18 @@
"i18nFormat": true,
"type": "Converter",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/convert/json.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-json/2026.0.0/metaforge-convert-json-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-json/2026.0.0/metaforge-convert-json-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-json/2026.0.0/metaforge-convert-json-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/convert/metaforge-convert-json-bin.tar.gz"
+ }
},
{
"key": "metaforge-convert-xml",
@@ -822,13 +1395,18 @@
"i18nFormat": true,
"type": "Converter",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/convert/xml.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-xml/2026.0.0/metaforge-convert-xml-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-xml/2026.0.0/metaforge-convert-xml-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-xml/2026.0.0/metaforge-convert-xml-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/convert/metaforge-convert-xml-bin.tar.gz"
+ }
},
{
"key": "metaforge-convert-none",
@@ -837,28 +1415,38 @@
"i18nFormat": true,
"type": "Converter",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/convert/none.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-none/2026.0.0/metaforge-convert-none-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-none/2026.0.0/metaforge-convert-none-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-convert-none/2026.0.0/metaforge-convert-none-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/convert/metaforge-convert-none-bin.tar.gz"
+ }
},
{
- "key": "metaforge-executor-local",
- "label": "Local Executor",
- "description": "A local execution plugin for MetaForge.",
+ "key": "metaforge-executor-metaforge",
+ "label": "MetaForge Executor",
+ "description": "The built-in MetaForge executor for data sync and migration.",
"i18nFormat": true,
"type": "Executor",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/executor/local.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-executor-local/2026.0.0/metaforge-executor-local-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-executor-metaforge/2026.0.0/metaforge-executor-metaforge-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-executor-metaforge/2026.0.0/metaforge-executor-metaforge-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/executor/metaforge-executor-metaforge-bin.tar.gz"
+ }
},
{
"key": "metaforge-executor-seatunnel",
@@ -867,13 +1455,18 @@
"i18nFormat": true,
"type": "Executor",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/executor/seatunnel.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-executor-seatunnel/2026.0.0/metaforge-executor-seatunnel-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-executor-seatunnel/2026.0.0/metaforge-executor-seatunnel-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-executor-seatunnel/2026.0.0/metaforge-executor-seatunnel-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/executor/metaforge-executor-seatunnel-bin.tar.gz"
+ }
},
{
"key": "metaforge-scheduler-local",
@@ -882,13 +1475,18 @@
"i18nFormat": true,
"type": "Scheduler",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/scheduler/local.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-scheduler-local/2026.0.0/metaforge-scheduler-local-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-scheduler-local/2026.0.0/metaforge-scheduler-local-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-scheduler-local/2026.0.0/metaforge-scheduler-local-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/scheduler/metaforge-scheduler-local-bin.tar.gz"
+ }
},
{
"key": "metaforge-fs-local",
@@ -897,13 +1495,18 @@
"i18nFormat": true,
"type": "FileSystem",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/fs/local.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-local/2026.0.0/metaforge-fs-local-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-local/2026.0.0/metaforge-fs-local-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-local/2026.0.0/metaforge-fs-local-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/fs/metaforge-fs-local-bin.tar.gz"
+ }
},
{
"key": "metaforge-fs-qiniu",
@@ -912,13 +1515,18 @@
"i18nFormat": true,
"type": "FileSystem",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/fs/qiniu.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-qiniu/2026.0.0/metaforge-fs-qiniu-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-qiniu/2026.0.0/metaforge-fs-qiniu-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-qiniu/2026.0.0/metaforge-fs-qiniu-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/fs/metaforge-fs-qiniu-bin.tar.gz"
+ }
},
{
"key": "metaforge-fs-alioss",
@@ -927,13 +1535,18 @@
"i18nFormat": true,
"type": "FileSystem",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/fs/alioss.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-alioss/2026.0.0/metaforge-fs-alioss-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-alioss/2026.0.0/metaforge-fs-alioss-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-alioss/2026.0.0/metaforge-fs-alioss-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/fs/metaforge-fs-alioss-bin.tar.gz"
+ }
},
{
"key": "metaforge-fs-tencent-cos",
@@ -942,13 +1555,18 @@
"i18nFormat": true,
"type": "FileSystem",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/fs/tencent-cos.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-tencent-cos/2026.0.0/metaforge-fs-tencent-cos-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-tencent-cos/2026.0.0/metaforge-fs-tencent-cos-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-tencent-cos/2026.0.0/metaforge-fs-tencent-cos-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/fs/metaforge-fs-tencent-cos-bin.tar.gz"
+ }
},
{
"key": "metaforge-fs-amazon-s3",
@@ -957,13 +1575,18 @@
"i18nFormat": true,
"type": "FileSystem",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/fs/amazon-s3.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-amazon-s3/2026.0.0/metaforge-fs-amazon-s3-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-amazon-s3/2026.0.0/metaforge-fs-amazon-s3-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-amazon-s3/2026.0.0/metaforge-fs-amazon-s3-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/fs/metaforge-fs-amazon-s3-bin.tar.gz"
+ }
},
{
"key": "metaforge-fs-minio",
@@ -972,13 +1595,34 @@
"i18nFormat": true,
"type": "FileSystem",
"version": "2026.0.0",
+ "requires": "2026.0.0",
"author": "metaforge-community",
"logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/fs/minio.svg",
- "released": "2026-01-28 16:48:33",
"supportVersion": [
"ALL"
],
- "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-minio/2026.0.0/metaforge-fs-minio-2026.0.0-bin.tar.gz"
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-minio/2026.0.0/metaforge-fs-minio-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-fs-minio/2026.0.0/metaforge-fs-minio-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/fs/metaforge-fs-minio-bin.tar.gz"
+ }
+ },
+ {
+ "key": "metaforge-notify-dingtalk",
+ "label": "DingTalk",
+ "description": "Sends MetaForge notifications to DingTalk group robots.",
+ "i18nFormat": false,
+ "type": "Notify",
+ "version": "2026.0.0",
+ "requires": "2026.0.0",
+ "author": "metaforge-community",
+ "features": [],
+ "url": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-notify-dingtalk/2026.0.0/metaforge-notify-dingtalk-2026.0.0-bin.tar.gz",
+ "sources": {
+ "maven": "https://repo1.maven.org/maven2/org/devlive/metaforge/metaforge-notify-dingtalk/2026.0.0/metaforge-notify-dingtalk-2026.0.0-bin.tar.gz",
+ "cdn": "https://cdn.north.devlive.org/applications/metaforge/plugins/2026.0.0/plugin/metaforge-notify-dingtalk-bin.tar.gz"
+ }
}
]
-}
\ No newline at end of file
+}
diff --git a/configure/publish/build-metadata.mjs b/configure/publish/build-metadata.mjs
new file mode 100644
index 0000000000..9e66357a1a
--- /dev/null
+++ b/configure/publish/build-metadata.mjs
@@ -0,0 +1,176 @@
+#!/usr/bin/env node
+// Builds the plugin store catalogue (metadata.json) from every plugin module's
+// plugin.json, and optionally the ForgeStats release catalog. plugin.json sits
+// next to capabilities.json in src/main/resources/org/devlive/metaforge/plugin,
+// so it also ships inside the plugin jar and the server can show it offline.
+//
+// node configure/publish/build-metadata.mjs # refresh configure/metadata.json
+// node configure/publish/build-metadata.mjs --dist dist/plugins \
+// --out dist/metadata.json --catalog dist/release-catalog.json \
+// --commit "$GITHUB_SHA" --generation "$GITHUB_RUN_NUMBER" --released now --strict
+//
+// plugin.json holds what only people know: label,
+// description, logo, type, author, supported data source versions (supportVersion),
+// the minimum MetaForge version (requires, defaults to the build version), extra
+// features and the store order. The build adds the rest: version, released time,
+// package size and SHA-256 from the built archive, download sources, and the
+// feature list derived from the capabilities the plugin declares in code.
+import { createHash } from 'node:crypto'
+import { createReadStream, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
+const MODULE_ROOTS = ['plugin', 'convert', 'executor', 'fs', 'scheduler', 'notify', 'parser']
+const DECLARATIONS = 'src/main/resources/org/devlive/metaforge/plugin'
+const CAPABILITIES = `${DECLARATIONS}/capabilities.json`
+const DESCRIPTOR = `${DECLARATIONS}/plugin.json`
+const MAVEN_BASE = 'https://repo1.maven.org/maven2'
+const CDN_BASE = 'https://cdn.north.devlive.org/applications/metaforge/plugins'
+const GROUP_PATH = 'org/devlive/metaforge'
+
+// Feature codes the store translates (store.feature.). Each is offered
+// when the plugin declares any of the listed management capabilities.
+const FEATURES = [
+ ['query', ['query:execute']],
+ ['database', ['database:list', 'database:info', 'database:alter']],
+ ['browse', ['table:list', 'table:info', 'table:read', 'tab:data']],
+ ['schema', ['table:create', 'table:alter', 'table:drop', 'table:truncate', 'column:create', 'column:modify', 'column:drop', 'column:reorder']],
+ ['ddl', ['table:statement', 'tab:statement']],
+ ['index', ['index:read', 'index:create', 'index:drop']],
+ ['foreignKey', ['foreign-key:read', 'foreign-key:create', 'foreign-key:drop']],
+ ['trigger', ['trigger:read', 'trigger:create', 'trigger:drop']],
+ ['dataEdit', ['data:insert', 'data:modify', 'data:delete']],
+ ['export', ['table:export']],
+ ['erDiagram', ['tab:er-diagram']],
+ ['keyValue', ['namespace:list', 'key:list', 'key:read']],
+ ['objectStorage', ['object:list', 'object:read', 'object:download']],
+]
+
+function args(argv) {
+ const out = { out: join(ROOT, 'configure/metadata.json') }
+ for (let i = 0; i < argv.length; i++) {
+ const flag = argv[i]
+ if (flag === '--strict') { out.strict = true; continue }
+ const value = argv[++i]
+ if (!flag.startsWith('--') || value === undefined) throw new Error(`Unknown or incomplete argument: ${flag}`)
+ out[flag.slice(2)] = value
+ }
+ return out
+}
+
+function projectVersion() {
+ const pom = readFileSync(join(ROOT, 'pom.xml'), 'utf8')
+ const project = pom.slice(pom.indexOf('metaforge '))
+ const match = project.match(/([^<]+)<\/version>/)
+ if (!match) throw new Error('Cannot read the project version from pom.xml')
+ return match[1].trim()
+}
+
+// The CDN layout of configure/publish/publish-oss-artifact.sh.
+function cdnDirectory(key) {
+ for (const type of ['executor', 'convert', 'fs', 'parser', 'scheduler']) {
+ if (key.startsWith(`metaforge-${type}-`)) return type
+ }
+ return 'plugin'
+}
+
+function features(moduleDir, extra) {
+ const file = join(moduleDir, CAPABILITIES)
+ const declared = new Set(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')).capabilities ?? [] : [])
+ const codes = FEATURES.filter(([, caps]) => caps.some(cap => declared.has(cap))).map(([code]) => code)
+ return [...codes, ...(Array.isArray(extra) ? extra.filter(item => typeof item === 'string' && item.trim()) : [])]
+}
+
+function sha256(file) {
+ return new Promise((done, fail) => {
+ const hash = createHash('sha256')
+ createReadStream(file).on('data', chunk => hash.update(chunk)).on('error', fail).on('end', () => done(hash.digest('hex')))
+ })
+}
+
+function modules() {
+ const found = []
+ for (const root of MODULE_ROOTS) {
+ const base = join(ROOT, root)
+ if (!existsSync(base)) continue
+ for (const name of readdirSync(base)) {
+ const dir = join(base, name)
+ if (existsSync(join(dir, DESCRIPTOR)) && existsSync(join(dir, 'pom.xml'))) found.push({ key: name, dir })
+ }
+ }
+ return found
+}
+
+async function main() {
+ const opts = args(process.argv.slice(2))
+ const version = opts.version ?? projectVersion()
+ const released = opts.released === 'now' ? new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, '') : opts.released
+ const children = []
+ const packages = []
+ const missing = []
+ for (const { key, dir } of modules()) {
+ const config = JSON.parse(readFileSync(join(dir, DESCRIPTOR), 'utf8'))
+ if (typeof config.label !== 'string' || !config.label.trim()) throw new Error(`${key}/${DESCRIPTOR} needs a label`)
+ const mavenPath = `${GROUP_PATH}/${key}/${version}/${key}-${version}-bin.tar.gz`
+ const cdnPath = `${version}/${cdnDirectory(key)}/${key}-bin.tar.gz`
+ const entry = {
+ key,
+ label: config.label,
+ description: config.description,
+ i18nFormat: config.i18nFormat === true,
+ type: config.type,
+ version,
+ requires: config.requires ?? version,
+ author: config.author,
+ logo: config.logo,
+ released,
+ supportVersion: Array.isArray(config.supportVersion) ? config.supportVersion : undefined,
+ features: features(dir, config.features),
+ // url stays the Maven Central package for MetaForge versions before the
+ // multi-source store; newer servers pick from sources.
+ url: `${MAVEN_BASE}/${mavenPath}`,
+ sources: { maven: `${MAVEN_BASE}/${mavenPath}`, cdn: `${CDN_BASE}/${cdnPath}` },
+ order: Number.isFinite(config.order) ? config.order : Number.MAX_SAFE_INTEGER,
+ }
+ if (opts.dist) {
+ const archive = join(resolve(opts.dist), `${key}-bin.tar.gz`)
+ if (existsSync(archive)) {
+ entry.size = statSync(archive).size
+ entry.sha256 = await sha256(archive)
+ packages.push({ id: key, version, sha256: entry.sha256, size: entry.size, status: 'active', sources: { maven: mavenPath, cdn: cdnPath } })
+ }
+ else {
+ missing.push(key)
+ }
+ }
+ children.push(entry)
+ }
+ if (opts.strict && missing.length) throw new Error(`Missing built packages: ${missing.join(', ')}`)
+ children.sort((a, b) => a.order - b.order || a.key.localeCompare(b.key))
+ for (const entry of children) delete entry.order
+ const metadata = {
+ key: 'plugin',
+ label: 'common.plugin.list.name',
+ description: 'common.plugin.list.description',
+ i18nFormat: true,
+ version,
+ children: JSON.parse(JSON.stringify(children)),
+ }
+ writeFileSync(resolve(opts.out), JSON.stringify(metadata, null, 2) + '\n')
+ console.log(`metadata: ${children.length} plugin(s) -> ${opts.out}${missing.length ? ` (${missing.length} without built package)` : ''}`)
+ if (opts.catalog) {
+ if (!/^[0-9a-f]{40}$/.test(opts.commit ?? '') || !/^[1-9]\d*$/.test(opts.generation ?? '')) {
+ throw new Error('--catalog needs --commit <40 hex> and --generation ')
+ }
+ if (!packages.length) throw new Error('--catalog needs --dist with built packages')
+ const catalog = { schemaVersion: 2, app: 'metaforge', sourceCommit: opts.commit, generation: Number(opts.generation), plugins: packages }
+ writeFileSync(resolve(opts.catalog), JSON.stringify(catalog, null, 2) + '\n')
+ console.log(`catalog: ${packages.length} package(s) -> ${opts.catalog}`)
+ }
+}
+
+main().catch(error => {
+ console.error(error.message)
+ process.exit(1)
+})
diff --git a/configure/publish/publish-artifact.sh b/configure/publish/publish-artifact.sh
index 51bd3c0563..f6d86208bc 100644
--- a/configure/publish/publish-artifact.sh
+++ b/configure/publish/publish-artifact.sh
@@ -36,8 +36,11 @@ for file in "$HOME/dist"/*bin.tar.gz; do
fi
done
-# Handle metadata file
-METADATA_FILE="${HOME}/configure/metadata.json"
+# Handle metadata file: built from the packages uploaded below (size, SHA-256,
+# sources, features). Release CI publishes the same file automatically; the
+# statistics catalog is imported only by CI, from the Maven Central archives.
+METADATA_FILE="${HOME}/dist/metadata.json"
+node "${HOME}/configure/publish/build-metadata.mjs" --dist "$PLUGIN_DIR" --out "$METADATA_FILE" --released now || exit 1
if [ -f "$METADATA_FILE" ]; then
# Upload new metadata file
echo "Uploading metadata file"
diff --git a/configure/publish/publish-oss-artifact.sh b/configure/publish/publish-oss-artifact.sh
index fb14ae314d..3965f9efa0 100644
--- a/configure/publish/publish-oss-artifact.sh
+++ b/configure/publish/publish-oss-artifact.sh
@@ -50,8 +50,11 @@ for file in "$HOME/dist"/*bin.tar.gz; do
fi
done
-# Handle metadata file
-METADATA_FILE="${HOME}/configure/metadata.json"
+# Handle metadata file: built from the packages uploaded below (size, SHA-256,
+# sources, features). Release CI publishes the same file automatically; the
+# statistics catalog is imported only by CI, from the Maven Central archives.
+METADATA_FILE="${HOME}/dist/metadata.json"
+node "${HOME}/configure/publish/build-metadata.mjs" --dist "$PLUGIN_DIR" --out "$METADATA_FILE" --released now || exit 1
if [ -f "$METADATA_FILE" ]; then
# Upload new metadata file
echo "Uploading metadata file"
diff --git a/convert/metaforge-convert-csv/src/main/kotlin/org/devlive/metaforge/convert/csv/CsvConvertService.kt b/convert/metaforge-convert-csv/src/main/kotlin/org/devlive/metaforge/convert/csv/CsvConvertService.kt
index e6a1a19585..00df35c9f7 100644
--- a/convert/metaforge-convert-csv/src/main/kotlin/org/devlive/metaforge/convert/csv/CsvConvertService.kt
+++ b/convert/metaforge-convert-csv/src/main/kotlin/org/devlive/metaforge/convert/csv/CsvConvertService.kt
@@ -4,7 +4,9 @@ import com.google.common.base.Preconditions.checkState
import org.devlive.metaforge.common.utils.DateUtils
import org.devlive.metaforge.convert.ConvertService
import org.devlive.metaforge.convert.FileConvert
+import org.devlive.metaforge.convert.model.ConvertOption
import org.devlive.metaforge.convert.model.ConvertRequest
+import org.devlive.metaforge.convert.model.choice
import org.devlive.metaforge.convert.model.ConvertResponse
import org.apache.commons.csv.CSVFormat
import org.apache.commons.io.FileUtils
@@ -75,6 +77,18 @@ class CsvConvertService : ConvertService
return response
}
+ override fun options(): List
+ {
+ return listOf(
+ ConvertOption("delimiter", mapOf("zh" to "分隔符", "en" to "Delimiter"), ",",
+ listOf(choice(",", "逗号 ,", "Comma ,"), choice(";", "分号 ;", "Semicolon ;"), choice("\t", "制表符", "Tab"), choice("|", "竖线 |", "Pipe |"))),
+ ConvertOption("header", mapOf("zh" to "首行字段名", "en" to "Header row"), "true",
+ listOf(choice("true", "包含", "Included"), choice("false", "不包含", "Left out"))),
+ ConvertOption("nulls", mapOf("zh" to "NULL 值处理", "en" to "NULL values"), "empty",
+ listOf(choice("empty", "空单元格", "Empty cell"), choice("NULL", "写为 NULL", "Write NULL")))
+ )
+ }
+
override fun writer(request: ConvertRequest): ConvertResponse
{
val response = ConvertResponse()
@@ -83,23 +97,29 @@ class CsvConvertService : ConvertService
log.info("${name()} writer start time [ ${DateUtils.now()} ]")
response.headers = request.headers
val file = FileConvert.formatFile(request, name())
- FileUtils.writeStringToFile(file, response.headers.joinToString(","), Charsets.UTF_8)
+ val delimiter = request.option("delimiter", ",").replace("\\t", "\t")
+ val nullText = if (request.excludeNull) "" else request.option("nulls", "empty").let { if (it == "NULL") "NULL" else "" }
+ val lines = mutableListOf()
+ if (request.option("header", "true") != "false")
+ {
+ lines.add(response.headers.joinToString(delimiter) { csvCell(it.toString(), delimiter) })
+ }
// The column types follow the header as a second line.
if (request.includeStructure)
{
- FileUtils.writeStringToFile(file, "\n" + request.headers.indices.joinToString(",") { request.types.getOrNull(it)?.toString() ?: "" }, Charsets.UTF_8, true)
+ lines.add(request.headers.indices.joinToString(delimiter) { csvCell(request.types.getOrNull(it)?.toString() ?: "", delimiter) })
}
val columns = mutableListOf()
request.columns
.forEach {
- FileUtils.writeStringToFile(file, "\n", Charsets.UTF_8, true)
when (it)
{
- is List<*> -> FileUtils.writeStringToFile(file, it.joinToString(separator = ",") { value -> if (value == null && request.excludeNull) "" else value.toString() }, Charsets.UTF_8, true)
- else -> FileUtils.writeStringToFile(file, it.toString(), Charsets.UTF_8, true)
+ is List<*> -> lines.add(it.joinToString(separator = delimiter) { value -> if (value == null) nullText else csvCell(value.toString(), delimiter) })
+ else -> lines.add(it.toString())
}
}
+ FileUtils.writeStringToFile(file, lines.joinToString("\n"), Charsets.UTF_8)
response.columns = columns
log.info("${name()} writer end time [ ${DateUtils.now()} ]")
response.successful = true
@@ -145,3 +165,13 @@ class CsvConvertService : ConvertService
return response
}
}
+
+/** A cell quoted when it holds the delimiter, a quote or a line break, quotes doubled (RFC 4180). */
+internal fun csvCell(value: String, delimiter: String): String
+{
+ return if (value.contains(delimiter) || value.contains('"') || value.contains('\n') || value.contains('\r'))
+ {
+ "\"" + value.replace("\"", "\"\"") + "\""
+ }
+ else value
+}
diff --git a/convert/metaforge-convert-csv/src/main/resources/org/devlive/metaforge/plugin/plugin.json b/convert/metaforge-convert-csv/src/main/resources/org/devlive/metaforge/plugin/plugin.json
new file mode 100644
index 0000000000..756b24ecf8
--- /dev/null
+++ b/convert/metaforge-convert-csv/src/main/resources/org/devlive/metaforge/plugin/plugin.json
@@ -0,0 +1,12 @@
+{
+ "label": "Csv Convert",
+ "description": "A CSV file conversion plugin for MetaForge.",
+ "i18nFormat": true,
+ "type": "Converter",
+ "author": "metaforge-community",
+ "logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/convert/csv.svg",
+ "supportVersion": [
+ "ALL"
+ ],
+ "order": 51
+}
diff --git a/convert/metaforge-convert-json/src/main/kotlin/org/devlive/metaforge/convert/json/JsonConvertService.kt b/convert/metaforge-convert-json/src/main/kotlin/org/devlive/metaforge/convert/json/JsonConvertService.kt
index 6868f709e4..e8b0515236 100644
--- a/convert/metaforge-convert-json/src/main/kotlin/org/devlive/metaforge/convert/json/JsonConvertService.kt
+++ b/convert/metaforge-convert-json/src/main/kotlin/org/devlive/metaforge/convert/json/JsonConvertService.kt
@@ -2,6 +2,8 @@ package org.devlive.metaforge.convert.json
import com.fasterxml.jackson.core.JsonEncoding
import com.fasterxml.jackson.core.JsonFactory
+import com.fasterxml.jackson.core.util.DefaultIndenter
+import com.fasterxml.jackson.core.util.DefaultPrettyPrinter
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
@@ -9,13 +11,16 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import org.devlive.metaforge.common.utils.DateUtils
import org.devlive.metaforge.convert.ConvertService
import org.devlive.metaforge.convert.FileConvert.formatFile
+import org.devlive.metaforge.convert.model.ConvertOption
import org.devlive.metaforge.convert.model.ConvertRequest
+import org.devlive.metaforge.convert.model.choice
import org.devlive.metaforge.convert.model.ConvertResponse
import org.slf4j.LoggerFactory.getLogger
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.time.LocalDateTime
+import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.Objects.requireNonNull
@@ -131,6 +136,20 @@ class JsonConvertService : ConvertService
return response
}
+ override fun options(): List
+ {
+ return listOf(
+ ConvertOption("root", mapOf("zh" to "JSON 根结构", "en" to "JSON root"), "array",
+ listOf(choice("array", "数组 Array", "Array"), choice("lines", "每行一个对象 JSON Lines", "One object per line (JSON Lines)"))),
+ ConvertOption("indent", mapOf("zh" to "缩进格式", "en" to "Indentation"), "2",
+ listOf(choice("0", "不缩进", "None"), choice("2", "2 个空格", "2 spaces"), choice("4", "4 个空格", "4 spaces"))),
+ ConvertOption("dateFormat", mapOf("zh" to "日期输出格式", "en" to "Date format"), "iso",
+ listOf(choice("iso", "ISO 8601", "ISO 8601"), choice("datetime", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm:ss"), choice("epoch", "时间戳(毫秒)", "Epoch milliseconds"))),
+ ConvertOption("nulls", mapOf("zh" to "NULL 值处理", "en" to "NULL values"), "keep",
+ listOf(choice("keep", "保留 null", "Keep null"), choice("omit", "省略字段", "Leave the field out")))
+ )
+ }
+
override fun writer(request: ConvertRequest): ConvertResponse
{
val response = ConvertResponse()
@@ -141,9 +160,20 @@ class JsonConvertService : ConvertService
val file = formatFile(request, name())
log.info("${name()} writer file absolute path [ ${file.absolutePath} ]")
+ val lines = request.option("root", "array") == "lines"
+ val indent = request.option("indent", "2").toIntOrNull() ?: 2
+ val dateFormat = request.option("dateFormat", "iso")
+ val omitNull = request.excludeNull || request.option("nulls", "keep") == "omit"
val factory = JsonFactory()
+ factory.setRootValueSeparator(if (lines) "\n" else " ")
factory.createGenerator(file, JsonEncoding.UTF8).use { generator ->
generator.codec = ObjectMapper()
+ // JSON Lines keeps every record on its own line, so it is never indented.
+ if (indent > 0 && ! lines)
+ {
+ val indenter = DefaultIndenter(" ".repeat(indent), DefaultIndenter.SYS_LF)
+ generator.prettyPrinter = DefaultPrettyPrinter().withObjectIndenter(indenter).withArrayIndenter(indenter)
+ }
if (request.includeStructure)
{
generator.writeStartObject()
@@ -155,9 +185,19 @@ class JsonConvertService : ConvertService
generator.writeEndObject()
}
generator.writeEndArray()
- generator.writeFieldName("rows")
+ if (lines)
+ {
+ generator.writeEndObject()
+ }
+ else
+ {
+ generator.writeFieldName("rows")
+ }
+ }
+ if (! lines)
+ {
+ generator.writeStartArray()
}
- generator.writeStartArray()
request.columns.forEach { column ->
generator.writeStartObject()
for (headerIndex in request.headers.indices)
@@ -167,25 +207,18 @@ class JsonConvertService : ConvertService
{
is List<*> ->
{
- val value = column[headerIndex]
- if (value == null && request.excludeNull)
+ val value = dateValue(column[headerIndex], dateFormat)
+ if (value == null && omitNull)
{
continue
}
- if (value is LocalDateTime)
- {
- generator.writeStringField(header, value.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
- }
- else
- {
- generator.writeObjectField(header, value)
- }
+ generator.writeObjectField(header, value)
}
is ObjectNode ->
{
val value = column.get(header)
- if ((value == null || value.isNull) && request.excludeNull)
+ if ((value == null || value.isNull) && omitNull)
{
continue
}
@@ -212,10 +245,13 @@ class JsonConvertService : ConvertService
}
generator.writeEndObject()
}
- generator.writeEndArray()
- if (request.includeStructure)
+ if (! lines)
{
- generator.writeEndObject()
+ generator.writeEndArray()
+ if (request.includeStructure)
+ {
+ generator.writeEndObject()
+ }
}
}
@@ -232,7 +268,25 @@ class JsonConvertService : ConvertService
return response
}
- private fun isDateTime(text: String): Boolean
+ /** A date or time value in the chosen format: ISO 8601, yyyy-MM-dd HH:mm:ss or epoch milliseconds. */
+private fun dateValue(value: Any?, format: String): Any?
+{
+ val time = when (value)
+ {
+ is LocalDateTime -> value
+ is java.sql.Timestamp -> value.toLocalDateTime()
+ is java.util.Date -> LocalDateTime.ofInstant(value.toInstant(), ZoneId.systemDefault())
+ else -> return value
+ }
+ return when (format)
+ {
+ "epoch" -> time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
+ "datetime" -> time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
+ else -> time.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
+ }
+}
+
+private fun isDateTime(text: String): Boolean
{
return try
{
diff --git a/convert/metaforge-convert-json/src/main/resources/org/devlive/metaforge/plugin/plugin.json b/convert/metaforge-convert-json/src/main/resources/org/devlive/metaforge/plugin/plugin.json
new file mode 100644
index 0000000000..23f06a63c9
--- /dev/null
+++ b/convert/metaforge-convert-json/src/main/resources/org/devlive/metaforge/plugin/plugin.json
@@ -0,0 +1,12 @@
+{
+ "label": "Json Convert",
+ "description": "A JSON file conversion plugin for MetaForge.",
+ "i18nFormat": true,
+ "type": "Converter",
+ "author": "metaforge-community",
+ "logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/convert/json.svg",
+ "supportVersion": [
+ "ALL"
+ ],
+ "order": 53
+}
diff --git a/convert/metaforge-convert-none/src/main/resources/org/devlive/metaforge/plugin/plugin.json b/convert/metaforge-convert-none/src/main/resources/org/devlive/metaforge/plugin/plugin.json
new file mode 100644
index 0000000000..5a3f6906d3
--- /dev/null
+++ b/convert/metaforge-convert-none/src/main/resources/org/devlive/metaforge/plugin/plugin.json
@@ -0,0 +1,12 @@
+{
+ "label": "None Convert",
+ "description": "A multidimensional arrays file conversion plugin for MetaForge.",
+ "i18nFormat": true,
+ "type": "Converter",
+ "author": "metaforge-community",
+ "logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/convert/none.svg",
+ "supportVersion": [
+ "ALL"
+ ],
+ "order": 55
+}
diff --git a/convert/metaforge-convert-spi/src/main/kotlin/org/devlive/metaforge/convert/ConvertService.kt b/convert/metaforge-convert-spi/src/main/kotlin/org/devlive/metaforge/convert/ConvertService.kt
index 70890a4e90..0d7b2fa512 100644
--- a/convert/metaforge-convert-spi/src/main/kotlin/org/devlive/metaforge/convert/ConvertService.kt
+++ b/convert/metaforge-convert-spi/src/main/kotlin/org/devlive/metaforge/convert/ConvertService.kt
@@ -1,5 +1,6 @@
package org.devlive.metaforge.convert
+import org.devlive.metaforge.convert.model.ConvertOption
import org.devlive.metaforge.convert.model.ConvertRequest
import org.devlive.metaforge.convert.model.ConvertResponse
import org.devlive.metaforge.plugin.Service
@@ -18,6 +19,18 @@ interface ConvertService : Service
return "Integrate ${name()} format convert"
}
+ /** Writer settings the plugin offers; values arrive in [ConvertRequest.options]. */
+ fun options(): List
+ {
+ return listOf()
+ }
+
+ /** Extension of the files the writer produces. */
+ fun extension(): String
+ {
+ return name().lowercase()
+ }
+
fun format(request: ConvertRequest): ConvertResponse
fun formatStream(request: ConvertRequest): ConvertResponse
diff --git a/convert/metaforge-convert-spi/src/main/kotlin/org/devlive/metaforge/convert/model/ConvertOption.kt b/convert/metaforge-convert-spi/src/main/kotlin/org/devlive/metaforge/convert/model/ConvertOption.kt
new file mode 100644
index 0000000000..a682054d54
--- /dev/null
+++ b/convert/metaforge-convert-spi/src/main/kotlin/org/devlive/metaforge/convert/model/ConvertOption.kt
@@ -0,0 +1,23 @@
+package org.devlive.metaforge.convert.model
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
+
+/**
+ * A setting a convert plugin declares for its writer, shown by the export page as a field: a
+ * choice among [choices] or free text when there are none. Labels are keyed by language (zh, en).
+ */
+@SuppressFBWarnings(value = ["EI_EXPOSE_REP", "EI_EXPOSE_REP2"], justification = "Read-only description of a plugin setting")
+data class ConvertOption(
+ val name: String,
+ val labels: Map,
+ val defaultValue: String,
+ val choices: List = listOf()
+)
+
+@SuppressFBWarnings(value = ["EI_EXPOSE_REP", "EI_EXPOSE_REP2"], justification = "Read-only description of a choice")
+data class ConvertChoice(
+ val value: String,
+ val labels: Map
+)
+
+fun choice(value: String, zh: String, en: String) = ConvertChoice(value, mapOf("zh" to zh, "en" to en))
diff --git a/convert/metaforge-convert-spi/src/main/kotlin/org/devlive/metaforge/convert/model/ConvertRequest.kt b/convert/metaforge-convert-spi/src/main/kotlin/org/devlive/metaforge/convert/model/ConvertRequest.kt
index 93749726c9..b3f1fcdb25 100644
--- a/convert/metaforge-convert-spi/src/main/kotlin/org/devlive/metaforge/convert/model/ConvertRequest.kt
+++ b/convert/metaforge-convert-spi/src/main/kotlin/org/devlive/metaforge/convert/model/ConvertRequest.kt
@@ -1,7 +1,9 @@
package org.devlive.metaforge.convert.model
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import java.io.InputStream
+@SuppressFBWarnings(value = ["EI_EXPOSE_REP", "EI_EXPOSE_REP2"], justification = "A request carries the caller's values")
class ConvertRequest
{
private var _headers: List = listOf()
@@ -15,6 +17,15 @@ class ConvertRequest
// Currently only valid for txt format
var delimiter: String? = null
+ // Values of the plugin's declared options (see ConvertService.options), by option name
+ var options: Map = mapOf()
+
+ /** An option's value, or [fallback] when it was not given. */
+ fun option(name: String, fallback: String): String
+ {
+ return options[name]?.takeIf { it.isNotEmpty() } ?: fallback
+ }
+
// The format type of the original data
var format: String? = null
diff --git a/convert/metaforge-convert-txt/src/main/kotlin/org/devlive/metaforge/convert/TxtConvertService.kt b/convert/metaforge-convert-txt/src/main/kotlin/org/devlive/metaforge/convert/TxtConvertService.kt
index 54f7c2c49e..c5cbf7f855 100644
--- a/convert/metaforge-convert-txt/src/main/kotlin/org/devlive/metaforge/convert/TxtConvertService.kt
+++ b/convert/metaforge-convert-txt/src/main/kotlin/org/devlive/metaforge/convert/TxtConvertService.kt
@@ -3,7 +3,9 @@ package org.devlive.metaforge.convert
import com.google.common.base.Preconditions.checkArgument
import com.google.common.base.Preconditions.checkState
import org.devlive.metaforge.common.utils.DateUtils
+import org.devlive.metaforge.convert.model.ConvertOption
import org.devlive.metaforge.convert.model.ConvertRequest
+import org.devlive.metaforge.convert.model.choice
import org.devlive.metaforge.convert.model.ConvertResponse
import org.apache.commons.io.FileUtils
import org.apache.commons.lang3.ObjectUtils.isNotEmpty
@@ -96,12 +98,28 @@ class TxtConvertService : ConvertService
return response
}
+ override fun options(): List
+ {
+ return listOf(
+ ConvertOption("delimiter", mapOf("zh" to "分隔符", "en" to "Delimiter"), "\t",
+ listOf(choice("\t", "制表符", "Tab"), choice(",", "逗号 ,", "Comma ,"), choice(" ", "空格", "Space"), choice("|", "竖线 |", "Pipe |"))),
+ ConvertOption("header", mapOf("zh" to "首行字段名", "en" to "Header row"), "true",
+ listOf(choice("true", "包含", "Included"), choice("false", "不包含", "Left out"))),
+ ConvertOption("nulls", mapOf("zh" to "NULL 值处理", "en" to "NULL values"), "empty",
+ listOf(choice("empty", "留空", "Empty"), choice("NULL", "写为 NULL", "Write NULL")))
+ )
+ }
+
override fun writer(request: ConvertRequest): ConvertResponse
{
val response = ConvertResponse()
try
{
+ // A declared delimiter option wins over the request's delimiter.
+ request.options["delimiter"]?.takeIf { it.isNotEmpty() }?.let { request.delimiter = it.replace("\\t", "\t") }
checkArgument(isNotEmpty(request.delimiter), "Delimiter must not be empty")
+ val header = request.option("header", "true") != "false"
+ val nullText = if (request.excludeNull) "" else request.option("nulls", "empty").let { if (it == "NULL") "NULL" else "" }
log.info("${name()} writer origin path [ ${request.path} ]")
log.info("${name()} writer start time [ ${DateUtils.now()} ]")
@@ -111,10 +129,7 @@ class TxtConvertService : ConvertService
request.delimiter
?.let { delimiter ->
log.info("${name()} writer file headers start")
- request.headers
- .let { headers ->
- FileUtils.writeStringToFile(file, headers.joinToString(separator = delimiter), Charsets.UTF_8)
- }
+ FileUtils.writeStringToFile(file, if (header) request.headers.joinToString(separator = delimiter) else "", Charsets.UTF_8)
// The column types follow the header as a second line.
if (request.includeStructure)
{
@@ -129,7 +144,7 @@ class TxtConvertService : ConvertService
FileUtils.writeStringToFile(file, "\n", Charsets.UTF_8, true)
when (it)
{
- is List<*> -> FileUtils.writeStringToFile(file, it.joinToString(separator = delimiter) { value -> if (value == null && request.excludeNull) "" else value.toString() },
+ is List<*> -> FileUtils.writeStringToFile(file, it.joinToString(separator = delimiter) { value -> value?.toString() ?: nullText },
Charsets.UTF_8, true)
else -> FileUtils.writeStringToFile(file, it.toString(), Charsets.UTF_8, true)
}
diff --git a/convert/metaforge-convert-txt/src/main/resources/org/devlive/metaforge/plugin/plugin.json b/convert/metaforge-convert-txt/src/main/resources/org/devlive/metaforge/plugin/plugin.json
new file mode 100644
index 0000000000..89c7b95926
--- /dev/null
+++ b/convert/metaforge-convert-txt/src/main/resources/org/devlive/metaforge/plugin/plugin.json
@@ -0,0 +1,12 @@
+{
+ "label": "Txt Convert",
+ "description": "A TXT file conversion plugin for MetaForge.",
+ "i18nFormat": true,
+ "type": "Converter",
+ "author": "metaforge-community",
+ "logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/convert/txt.svg",
+ "supportVersion": [
+ "ALL"
+ ],
+ "order": 52
+}
diff --git a/convert/metaforge-convert-xml/src/main/kotlin/org/devlive/metaforge/convert/xml/XmlConvertService.kt b/convert/metaforge-convert-xml/src/main/kotlin/org/devlive/metaforge/convert/xml/XmlConvertService.kt
index 10e180da54..b37c9faa99 100644
--- a/convert/metaforge-convert-xml/src/main/kotlin/org/devlive/metaforge/convert/xml/XmlConvertService.kt
+++ b/convert/metaforge-convert-xml/src/main/kotlin/org/devlive/metaforge/convert/xml/XmlConvertService.kt
@@ -6,7 +6,9 @@ import com.fasterxml.jackson.dataformat.xml.XmlMapper
import org.devlive.metaforge.common.utils.DateUtils
import org.devlive.metaforge.convert.ConvertService
import org.devlive.metaforge.convert.FileConvert.formatFile
+import org.devlive.metaforge.convert.model.ConvertOption
import org.devlive.metaforge.convert.model.ConvertRequest
+import org.devlive.metaforge.convert.model.choice
import org.devlive.metaforge.convert.model.ConvertResponse
import org.slf4j.LoggerFactory.getLogger
import java.io.ByteArrayOutputStream
@@ -66,6 +68,16 @@ class XmlConvertService : ConvertService
TODO("Not yet implemented")
}
+ override fun options(): List
+ {
+ return listOf(
+ ConvertOption("rootElement", mapOf("zh" to "根元素名", "en" to "Root element"), root),
+ ConvertOption("rowElement", mapOf("zh" to "行元素名", "en" to "Row element"), node),
+ ConvertOption("nulls", mapOf("zh" to "NULL 值处理", "en" to "NULL values"), "keep",
+ listOf(choice("keep", "写为 null", "Write null"), choice("omit", "省略属性", "Leave the attribute out")))
+ )
+ }
+
override fun writer(request: ConvertRequest): ConvertResponse
{
val response = ConvertResponse()
@@ -80,7 +92,9 @@ class XmlConvertService : ConvertService
.use { generator ->
generator.codec = XmlMapper()
val staxWriter = generator.staxWriter
- staxWriter.writeStartElement(root)
+ val rowElement = request.option("rowElement", node)
+ val omitNull = request.excludeNull || request.option("nulls", "keep") == "omit"
+ staxWriter.writeStartElement(request.option("rootElement", root))
if (request.includeStructure)
{
staxWriter.writeStartElement("Columns")
@@ -94,7 +108,7 @@ class XmlConvertService : ConvertService
}
request.columns
.forEach { column ->
- staxWriter.writeStartElement(node)
+ staxWriter.writeStartElement(rowElement)
for (headerIndex in request.headers.indices)
{
when (column)
@@ -102,7 +116,7 @@ class XmlConvertService : ConvertService
is List<*> ->
{
val value = column[headerIndex]
- if (value != null || !request.excludeNull)
+ if (value != null || ! omitNull)
{
staxWriter.writeAttribute(request.headers[headerIndex].toString(), value.toString())
}
diff --git a/convert/metaforge-convert-xml/src/main/resources/org/devlive/metaforge/plugin/plugin.json b/convert/metaforge-convert-xml/src/main/resources/org/devlive/metaforge/plugin/plugin.json
new file mode 100644
index 0000000000..e8007c2faf
--- /dev/null
+++ b/convert/metaforge-convert-xml/src/main/resources/org/devlive/metaforge/plugin/plugin.json
@@ -0,0 +1,12 @@
+{
+ "label": "Xml Convert",
+ "description": "A XML file conversion plugin for MetaForge.",
+ "i18nFormat": true,
+ "type": "Converter",
+ "author": "metaforge-community",
+ "logo": "http://devlive-cdn.oss-cn-beijing.aliyuncs.com/applications/metaforge/resources/logo/convert/xml.svg",
+ "supportVersion": [
+ "ALL"
+ ],
+ "order": 54
+}
diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/Plugin.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/Plugin.java
index dc9c714e22..efe297be27 100644
--- a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/Plugin.java
+++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/Plugin.java
@@ -231,6 +231,42 @@ public java.util.List testRuntimeConfigures(java.util.Map values)
+ {
+ java.util.List problems = new java.util.ArrayList<>();
+ for (org.devlive.metaforge.plugin.configure.RuntimeConfigureField field : runtimeConfigures()) {
+ String value = values.get(field.getName());
+ if (value == null || value.trim().isEmpty()) {
+ continue;
+ }
+ if (field.getType() == org.devlive.metaforge.plugin.configure.RuntimeConfigureFieldType.NUMBER) {
+ try {
+ Double.parseDouble(value.trim());
+ }
+ catch (NumberFormatException e) {
+ problems.add(field.getName() + " is not a number: " + value);
+ }
+ }
+ else if (field.getType() == org.devlive.metaforge.plugin.configure.RuntimeConfigureFieldType.BOOLEAN
+ && !"true".equalsIgnoreCase(value.trim()) && !"false".equalsIgnoreCase(value.trim())) {
+ problems.add(field.getName() + " is not true or false: " + value);
+ }
+ else if (field.getType() == org.devlive.metaforge.plugin.configure.RuntimeConfigureFieldType.SELECT
+ && field.getOptions() != null && !field.getOptions().contains(value.trim())) {
+ problems.add(field.getName() + " must be one of " + field.getOptions() + ": " + value);
+ }
+ }
+ return problems;
+ }
+
/**
* 声明该插件的数据源表单配置字段(字段名 / 类型 / 必填 / 默认值 / 分组 / pipelines 等)。
* 连接器插件默认返回通用 JDBC 字段(原 default.yaml 语义),转换等其余类型返回 null。
@@ -353,7 +389,7 @@ public T getService(Class serviceClass, String name)
}
// 获取所有服务
-// Get all services
+ // Get all services
public Set getAllServices(Class serviceClass)
{
validateInjector();
diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginDescriptor.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginDescriptor.java
new file mode 100644
index 0000000000..7edda43f8d
--- /dev/null
+++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginDescriptor.java
@@ -0,0 +1,130 @@
+package org.devlive.metaforge.plugin;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.security.CodeSource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * The store details a plugin ships in {@code org/devlive/metaforge/plugin/plugin.json}, next to its
+ * capabilities.json: label, description, logo, type, author, supported data source versions, the
+ * minimum MetaForge version and extra features. The release build turns the same file into the
+ * store catalogue, so an installed plugin can still describe itself when the catalogue is
+ * unavailable or does not list it (self-built plugins).
+ */
+@Slf4j
+public final class PluginDescriptor
+{
+ /** The descriptor path inside every plugin jar or classes directory. */
+ public static final String FILE = "org/devlive/metaforge/plugin/plugin.json";
+
+ private static final Set TEXT = Set.of("label", "description", "logo", "type", "author", "requires");
+ private static final Set LISTS = Set.of("supportVersion", "features");
+ private static final ObjectMapper JSON = new ObjectMapper();
+
+ // Read once per plugin class: the file ships inside the plugin and never changes while it is loaded.
+ private static final ClassValue> DECLARED = new ClassValue<>()
+ {
+ @Override
+ protected Map computeValue(Class> type)
+ {
+ return load(type);
+ }
+ };
+
+ private PluginDescriptor()
+ {
+ }
+
+ /** The descriptor of the plugin the class belongs to; empty when the plugin has none. */
+ public static Map of(Class> plugin)
+ {
+ return plugin == null ? Collections.emptyMap() : DECLARED.get(plugin);
+ }
+
+ private static Map load(Class> plugin)
+ {
+ URL descriptor = descriptor(plugin);
+ if (descriptor == null) {
+ return Collections.emptyMap();
+ }
+ try {
+ URLConnection connection = descriptor.openConnection();
+ // Plugins are reloaded from their jars; a cached jar would stay open and stale.
+ connection.setUseCaches(false);
+ try (InputStream stream = connection.getInputStream()) {
+ return read(stream);
+ }
+ }
+ catch (FileNotFoundException error) {
+ return Collections.emptyMap();
+ }
+ catch (IOException error) {
+ // A broken descriptor only costs the offline store details, never the plugin itself.
+ log.warn("Ignoring unreadable plugin descriptor {}: {}", descriptor, error.getMessage());
+ return Collections.emptyMap();
+ }
+ }
+
+ /** Keeps the known fields with the expected types; anything else is ignored. */
+ static Map read(InputStream stream)
+ throws IOException
+ {
+ JsonNode root = JSON.readTree(stream);
+ if (root == null || !root.isObject()) {
+ return Collections.emptyMap();
+ }
+ Map out = new LinkedHashMap<>();
+ root.fields().forEachRemaining(field -> {
+ JsonNode value = field.getValue();
+ if (TEXT.contains(field.getKey()) && value.isTextual() && !value.asText().isBlank()) {
+ out.put(field.getKey(), value.asText());
+ }
+ else if (LISTS.contains(field.getKey()) && value.isArray()) {
+ List items = new ArrayList<>();
+ value.forEach(item -> {
+ if (item.isTextual() && !item.asText().isBlank()) {
+ items.add(item.asText());
+ }
+ });
+ out.put(field.getKey(), Collections.unmodifiableList(items));
+ }
+ else if ("i18nFormat".equals(field.getKey()) && value.isBoolean()) {
+ out.put(field.getKey(), value.asBoolean());
+ }
+ });
+ return Collections.unmodifiableMap(out);
+ }
+
+ /**
+ * The plugin.json in the jar or classes directory the class comes from. Every plugin uses the
+ * same path, so a lookup by name could find another plugin's file on a shared class path.
+ */
+ private static URL descriptor(Class> type)
+ {
+ CodeSource source = type.getProtectionDomain() == null ? null : type.getProtectionDomain().getCodeSource();
+ if (source == null || source.getLocation() == null) {
+ return null;
+ }
+ try {
+ String location = source.getLocation().toString();
+ return location.endsWith(".jar") ? new URL("jar:" + location + "!/" + FILE) : new URL(source.getLocation(), FILE);
+ }
+ catch (MalformedURLException error) {
+ return null;
+ }
+ }
+}
diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginManager.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginManager.java
index 20e06ea477..7a2d0caeab 100644
--- a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginManager.java
+++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginManager.java
@@ -44,6 +44,7 @@
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
@Slf4j
@SuppressFBWarnings(value = {"NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE", "EI_EXPOSE_REP2"})
@@ -78,6 +79,13 @@ public class PluginManager
// Default version
private static final String DEFAULT_VERSION = "1.0.0";
+ // 已改名插件的旧名称 → 新名称,仅在按旧名称找不到插件时使用
+ // Former plugin names to their current names, used only when nothing is registered under the former name
+ private static final Map RENAMED_PLUGINS = Map.of(
+ "Local", "MetaForge",
+ "LocalExecutor", "MetaForge"
+ );
+
// 插件监视器
// Plugin watcher
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
@@ -202,30 +210,32 @@ public boolean installPlugin(Path sourcePath, String targetDirectory)
// Handle installation based on plugin type
installed = processPluginInstallation(sourcePath, tempDir);
- if (installed) {
- // 创建最终目标目录
- // Create final target directory
- Path targetPath = config.getPluginsDir().resolve(targetDirectory);
-
- // 如果目标目录已存在,先备份
- // Backup existing target directory if it exists
- if (Files.exists(targetPath)) {
- backupExistingPlugin(targetPath);
- }
+ if (!installed) {
+ return false;
+ }
- // 将临时目录移动到最终位置
- // Move temporary directory to final location
+ Path targetPath = config.getPluginsDir().resolve(targetDirectory);
+ Path backup = backupExistingPlugin(targetPath);
+ boolean loaded = false;
+ try {
moveDirectory(tempDir, targetPath);
-
- // 加载新安装的插件
- // Load newly installed plugin
- loadPluginFromDirectory(targetPath);
-
+ List modules = preparePlugins(targetPath);
+ if (modules.isEmpty()) {
+ return false;
+ }
+ modules.forEach(this::registerPlugin);
+ loaded = true;
log.info("Successfully installed plugin from {} to {}", sourcePath, targetPath);
return true;
}
-
- return false;
+ finally {
+ if (!loaded) {
+ deleteDirectory(targetPath);
+ if (backup != null) {
+ moveDirectory(backup, targetPath);
+ }
+ }
+ }
}
catch (Exception e) {
log.error("Failed to install plugin from: {} to {}", sourcePath, targetDirectory, e);
@@ -263,7 +273,7 @@ private boolean processPluginInstallation(Path sourcePath, Path tempDir)
}
// Properties 插件
// Properties plugin
- if ("properties".equals(extension)) {
+ else if ("properties".equals(extension)) {
installed.set(installPropertiesPlugin(sourcePath, tempDir));
}
// POM 插件
@@ -306,21 +316,21 @@ else if (isSpiPlugin(sourcePath)) {
// 备份现有插件
// Backup existing plugin
- private void backupExistingPlugin(Path pluginPath)
+ private Path backupExistingPlugin(Path pluginPath)
throws IOException
{
- if (Files.exists(pluginPath)) {
- String timestamp = DateUtils.formatYMDHMS();
- Path backupPath = pluginPath.getParent().resolve(pluginPath.getFileName() + ".backup." + timestamp);
-
- try {
- Files.move(pluginPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
- log.info("Created backup of existing plugin: {}", backupPath);
- }
- catch (IOException e) {
- log.error("Failed to create backup of existing plugin: {}", pluginPath, e);
- throw e;
- }
+ if (!Files.exists(pluginPath)) {
+ return null;
+ }
+ Path backup = Files.createTempDirectory(pluginPath.getParent(), pluginPath.getFileName() + ".backup.");
+ try {
+ Files.move(pluginPath, backup, StandardCopyOption.REPLACE_EXISTING);
+ log.info("Created backup of existing plugin: {}", backup);
+ return backup;
+ }
+ catch (IOException e) {
+ Files.deleteIfExists(backup);
+ throw e;
}
}
@@ -490,43 +500,21 @@ private boolean installSpiPlugin(Path sourcePath, Path targetPath)
private boolean installTarPlugin(Path sourcePath, Path tempDir)
throws IOException
{
- // 直接使用 TarPluginLoader 处理
- // Directly use TarPluginLoader to handle
- TarPluginLoader tarPluginLoader = new TarPluginLoader();
- // 先加载插件到临时目录
- // Load plugins to temporary directory
- List plugins = tarPluginLoader.load(sourcePath, tempDir, config.getParentClassLoaderPackages());
- if (!plugins.isEmpty()) {
- // 查找解压后的子目录
- // Find extracted subdirectory
- try (Stream paths = Files.list(tempDir)) {
- Optional subDir = paths
- .filter(Files::isDirectory)
- .findFirst();
-
- if (subDir.isPresent()) {
- // 如果存在子目录,将其内容移动到临时目录根目录
- // If subdirectory exists, move its contents to temp directory root
- Path source = subDir.get();
- try (Stream files = Files.list(source)) {
- files.forEach(file -> {
- try {
- Path target = tempDir.resolve(file.getFileName());
- Files.move(file, target);
- }
- catch (IOException e) {
- log.error("Failed to move file: {} to {}", file, tempDir, e);
- }
- });
- }
- // 删除空的子目录
- // Delete empty subdirectory
- Files.delete(source);
+ new TarPluginLoader().extractArchive(sourcePath, tempDir);
+ List entries;
+ try (Stream paths = Files.list(tempDir)) {
+ entries = paths.collect(Collectors.toList());
+ }
+ if (entries.size() == 1 && Files.isDirectory(entries.get(0))) {
+ Path directory = entries.get(0);
+ try (Stream files = Files.list(directory)) {
+ for (Path file : files.collect(Collectors.toList())) {
+ Files.move(file, tempDir.resolve(file.getFileName()));
}
}
- return true;
+ Files.delete(directory);
}
- return false;
+ return !entries.isEmpty();
}
// 复制目录
@@ -655,6 +643,8 @@ private List discoverPluginPaths()
try (Stream paths = Files.walk(root, config.getScanDepth() == 0 ? 1 : config.getScanDepth())) {
candidates = paths.filter(Files::isDirectory)
.filter(path -> !path.equals(root))
+ .filter(path -> StreamSupport.stream(root.relativize(path).spliterator(), false)
+ .noneMatch(segment -> isPluginStagingDirectory(segment.toString())))
.sorted()
.collect(Collectors.toList());
}
@@ -698,11 +688,9 @@ private void expandPluginPath(Path path, Set paths, Set manifests)
}
}
- // 安装时仍同步加载;启动/监视扫描复用同一初始化过程
- // Installation remains synchronous; startup and watcher scans share preparation
- private void loadPluginFromDirectory(Path pluginDir)
+ private static boolean isPluginStagingDirectory(String name)
{
- preparePlugins(pluginDir).forEach(this::registerPlugin);
+ return name.startsWith(TEMP_DIR_PREFIX) || name.contains(".backup.") || name.contains(".unload.");
}
private List preparePlugins(Path pluginDir)
@@ -773,8 +761,13 @@ private void registerPlugin(PluginMetadata metadata)
synchronized (installLock) {
String name = metadata.getName();
PluginMetadata oldPlugin = plugins.remove(name);
- if (oldPlugin != null && oldPlugin.getClassLoader() != metadata.getClassLoader()) {
- closePluginClassLoader(oldPlugin);
+ if (oldPlugin != null) {
+ if (oldPlugin.getState() == PluginState.STOPPED) {
+ metadata.setState(PluginState.STOPPED);
+ }
+ if (oldPlugin.getClassLoader() != metadata.getClassLoader()) {
+ closePluginClassLoader(oldPlugin);
+ }
}
PluginClassLoader loader = (PluginClassLoader) metadata.getClassLoader();
pluginClassLoaders.put(name, loader);
@@ -862,7 +855,16 @@ public void destroy()
// Get plugin by name
public Optional getPlugin(String name)
{
- return Optional.ofNullable(plugins.get(name))
+ PluginMetadata metadata = plugins.get(name);
+ if (metadata == null && name != null && RENAMED_PLUGINS.containsKey(name)) {
+ metadata = plugins.get(RENAMED_PLUGINS.get(name));
+ }
+ if (metadata != null && metadata.getState() == PluginState.STOPPED) {
+ // 禁用后的插件不再对外提供服务,直到重新启用
+ // A stopped plugin is out of service until it is enabled again
+ return Optional.empty();
+ }
+ return Optional.ofNullable(metadata)
.map(info -> (Plugin) info.getInstance());
}
@@ -887,6 +889,29 @@ public List getPluginInfos()
return new ArrayList<>(plugins.values());
}
+ // 启用或禁用插件(仅切换运行状态标记,不卸载类加载器)
+ // Enable or disable a plugin (toggle the runtime flag only; class loaders stay loaded)
+ public boolean setPluginState(String name, boolean enabled)
+ {
+ if (name == null || name.trim().isEmpty()) {
+ log.warn("Plugin name cannot be null or empty");
+ return false;
+ }
+ synchronized (installLock) {
+ PluginMetadata metadata = plugins.get(name);
+ if (metadata == null && RENAMED_PLUGINS.containsKey(name)) {
+ metadata = plugins.get(RENAMED_PLUGINS.get(name));
+ }
+ if (metadata == null || metadata.getInstance() == null
+ || metadata.getState() == PluginState.UNLOADED || metadata.getState() == PluginState.FAILED) {
+ return false;
+ }
+ metadata.setState(enabled ? PluginState.STARTED : PluginState.STOPPED);
+ log.info("Plugin {} state changed to {}", name, metadata.getState());
+ return true;
+ }
+ }
+
// 卸载指定名称的插件
// Unload plugin by name
public boolean uninstallPlugin(String name)
diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginMetadata.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginMetadata.java
index 3b7ca56720..ba4c38673f 100644
--- a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginMetadata.java
+++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginMetadata.java
@@ -6,6 +6,7 @@
import lombok.Data;
import java.nio.file.Path;
+import java.util.Map;
@Data
@Builder
@@ -30,4 +31,13 @@ public class PluginMetadata
@JsonIgnore
private Path location;
+
+ /**
+ * Store details the plugin ships in its own plugin.json (see {@link PluginDescriptor}), so the
+ * store can describe an installed plugin without the remote catalogue.
+ */
+ public Map getStore()
+ {
+ return instance == null ? Map.of() : PluginDescriptor.of(instance.getClass());
+ }
}
diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureField.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureField.java
index a688331f4f..894259f2d4 100644
--- a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureField.java
+++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureField.java
@@ -1,16 +1,21 @@
package org.devlive.metaforge.plugin.configure;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.util.List;
+
/**
* 描述一个插件可配置字段。所有插件类型(Executor / Scheduler / FS / …)共用。
* - name: 字段 key,序列化到 JSON 配置中的属性名
- * - type: 字段类型,决定 UI 渲染(文本框 / 数字 / 开关 / 密码)
+ * - type: 字段类型,决定 UI 渲染(文本框 / 数字 / 开关 / 密码 / 下拉)
* - defaultValue: 默认值的字符串表示;调用方按 type 解析
* - description: 字段说明,UI tooltip 用
* - tunable: true = 普通用户在触发任务时可临时覆盖;false = 仅管理员可在系统配置改
+ * - options: SELECT 类型的可选值
+ * - group: 字段分组 key,UI 按组展示;为空表示不分组
*
* Generic configurable-field descriptor used by any plugin category.
* - tunable=false fields are administrator-only.
@@ -19,6 +24,7 @@
@Data
@NoArgsConstructor
@AllArgsConstructor
+@SuppressFBWarnings(value = {"EI_EXPOSE_REP", "EI_EXPOSE_REP2"})
public class RuntimeConfigureField
{
private String name;
@@ -26,9 +32,21 @@ public class RuntimeConfigureField
private String defaultValue;
private String description;
private boolean tunable;
+ private List options;
+ private String group;
public RuntimeConfigureField(String name, RuntimeConfigureFieldType type, String defaultValue, String description)
{
this(name, type, defaultValue, description, false);
}
+
+ public RuntimeConfigureField(String name, RuntimeConfigureFieldType type, String defaultValue, String description, boolean tunable)
+ {
+ this(name, type, defaultValue, description, tunable, null, null);
+ }
+
+ public RuntimeConfigureField(String name, RuntimeConfigureFieldType type, String defaultValue, String description, boolean tunable, String group)
+ {
+ this(name, type, defaultValue, description, tunable, null, group);
+ }
}
diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureFieldType.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureFieldType.java
index 654f076f1a..49e39b46cb 100644
--- a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureFieldType.java
+++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureFieldType.java
@@ -13,5 +13,6 @@ public enum RuntimeConfigureFieldType
STRING,
NUMBER,
BOOLEAN,
- PASSWORD
+ PASSWORD,
+ SELECT
}
diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/CompiledPomPluginLoader.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/CompiledPomPluginLoader.java
index 599c245387..786e078198 100644
--- a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/CompiledPomPluginLoader.java
+++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/CompiledPomPluginLoader.java
@@ -6,6 +6,7 @@
import org.devlive.metaforge.plugin.utils.VersionUtils;
import java.io.File;
+import java.io.IOException;
import java.lang.reflect.Modifier;
import java.net.URL;
import java.nio.file.Files;
@@ -48,7 +49,6 @@ public List load(Path path, Set parentClassLoaderPackages)
// 获取编译后的类路径
// Get compiled classpath
Path targetClasses = path.resolve("target/classes");
- Path targetDependencies = path.resolve("target/dependency");
// 如果已编译的类不存在,直接返回空列表
// If compiled classes don't exist, return empty list
@@ -59,7 +59,7 @@ public List load(Path path, Set parentClassLoaderPackages)
// 创建类加载器
// Create class loader
- PluginClassLoader classLoader = createProjectClassLoader(targetClasses, targetDependencies, path, parentClassLoaderPackages);
+ PluginClassLoader classLoader = createProjectClassLoader(targetClasses, path, parentClassLoaderPackages);
// 查找并加载插件类
// Find and load plugin classes
@@ -73,35 +73,15 @@ public List load(Path path, Set parentClassLoaderPackages)
// 创建项目类加载器
// Create project class loader
- private PluginClassLoader createProjectClassLoader(Path targetClasses, Path targetDependencies, Path module, Set parentClassLoaderPackages)
+ private PluginClassLoader createProjectClassLoader(Path targetClasses, Path module, Set parentClassLoaderPackages)
throws Exception
{
List urls = new ArrayList<>();
-
- // 添加编译后的类路径
- // Add compiled classes path
log.debug("Adding classes directory to classpath: {}", targetClasses);
urls.add(targetClasses.toUri().toURL());
-
- // 添加所有依赖jar(如果存在)
- // Add all dependency jars (if exist)
- if (Files.exists(targetDependencies)) {
- log.debug("Adding dependencies from: {}", targetDependencies);
- try (Stream paths = Files.walk(targetDependencies)) {
- paths.filter(path -> path.toString().endsWith(".jar"))
- .forEach(path -> {
- try {
- log.debug("Adding dependency to classpath: {}", path);
- urls.add(path.toUri().toURL());
- }
- catch (Exception e) {
- log.error("Failed to add dependency jar to classpath: {}", path, e);
- }
- });
- }
- }
- else {
- log.debug("Dependencies directory not found: {}", targetDependencies);
+ for (Path jar : dependencyJars(module)) {
+ log.debug("Adding dependency to classpath: {}", jar);
+ urls.add(jar.toUri().toURL());
}
// Path.getFileName() may return null for empty paths, fall back to
@@ -166,4 +146,25 @@ private String getClassName(Path baseDir, Path classFile)
return relativePath.replace(File.separatorChar, '.')
.replace(".class", "");
}
+
+ /**
+ * The module's dependency jars: the plugin builds copy them to target/dependencies, while
+ * `mvn dependency:copy-dependencies` run by hand writes target/dependency. Without them a plugin
+ * finds only the drivers that happen to be on the server's own class path.
+ */
+ static List dependencyJars(Path module)
+ throws IOException
+ {
+ List jars = new ArrayList<>();
+ for (String directory : new String[] {"target/dependencies", "target/dependency"}) {
+ Path dependencies = module.resolve(directory);
+ if (!Files.isDirectory(dependencies)) {
+ continue;
+ }
+ try (Stream paths = Files.walk(dependencies)) {
+ paths.filter(path -> path.toString().endsWith(".jar")).sorted().forEach(jars::add);
+ }
+ }
+ return jars;
+ }
}
diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/PluginClassLoader.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/PluginClassLoader.java
index 7e0bad871d..e80bdf2e67 100644
--- a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/PluginClassLoader.java
+++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/PluginClassLoader.java
@@ -67,7 +67,14 @@ protected Class> loadClass(String name, boolean resolve)
// Add Plugin related packages to parent-first list
|| (parentFirst && name.startsWith("org.devlive.metaforge.plugin"))
) {
- return super.loadClass(name, resolve);
+ try {
+ return super.loadClass(name, resolve);
+ }
+ catch (ClassNotFoundException e) {
+ // 父加载器没有时再尝试插件自身的 jar(如驱动类)
+ // Fall back to the plugin's own jars (e.g. driver classes)
+ return findClass(name);
+ }
}
// 其他所有类先尝试从当前类加载器加载
diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/PluginLoaderFactory.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/PluginLoaderFactory.java
index 2c26a0108d..44adae9462 100644
--- a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/PluginLoaderFactory.java
+++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/PluginLoaderFactory.java
@@ -111,7 +111,17 @@ public static List loadPlugins(Path pluginDir, Set parentClassLo
List modules = loader.load(pluginDir, parentClassLoaderPackages);
if (modules != null && !modules.isEmpty()) {
log.info("Successfully loaded [ {} ] plugin(s) using loader type [ {} ]", modules.size(), type);
- modules.forEach(v -> v.setClassLoader(loader.getClass().getName()));
+ // 开发模式的 pom/编译目录加载器不设置 key,商店只能靠类名匹配目录清单而出现重复行;
+ // 统一以模块目录名(与 metadata.json 的 key 一致)兜底
+ // Development loaders leave the key unset, so the store matched by class name and listed
+ // plugins twice. Fall back to the module directory name, which equals the catalogue key.
+ Path module = pluginDir.toString().endsWith("pom.xml") && pluginDir.getParent() != null ? pluginDir.getParent() : pluginDir;
+ modules.forEach(v -> {
+ v.setClassLoader(loader.getClass().getName());
+ if (v.getKey() == null || v.getKey().isBlank()) {
+ v.setKey(loader.extractPluginName(module));
+ }
+ });
return modules;
}
}
diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/TarPluginLoader.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/TarPluginLoader.java
index 6a54f3954a..2e5addbf14 100644
--- a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/TarPluginLoader.java
+++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/loader/TarPluginLoader.java
@@ -8,19 +8,14 @@
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
-import org.apache.http.conn.ssl.NoopHostnameVerifier;
-import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
-import org.apache.http.ssl.SSLContextBuilder;
import org.devlive.metaforge.plugin.Plugin;
import org.devlive.metaforge.plugin.PluginContextManager;
import org.devlive.metaforge.plugin.SpiType;
import org.devlive.metaforge.plugin.utils.PluginClassLoaderUtils;
import org.devlive.metaforge.plugin.utils.VersionUtils;
-import javax.net.ssl.SSLContext;
-
import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
@@ -28,9 +23,6 @@
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.security.KeyManagementException;
-import java.security.KeyStoreException;
-import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.ServiceLoader;
@@ -92,43 +84,48 @@ public List load(Path path, Set parentClassLoaderPackages)
*/
public List load(Path path, Path targetDir, Set parentClassLoaderPackages)
{
+ Path extractDir = targetDir;
try {
- if (isValidTarPath(path)) {
- // 如果是 URL 路径,先下载到本地
- // If it's a URL path, download it first
- if (path.toString().startsWith("http") || path.toString().startsWith("https")) {
- path = downloadTarFile(path.toString().replace(":/", "://"));
- }
-
- if (isValidTarFile(path)) {
- // 使用指定的目录或创建临时目录
- // Use specified directory or create temporary directory
- Path extractDir = targetDir != null ? targetDir : createTempDirectory();
-
- // 解压 tar 文件
- // Extract tar file
- extractTarFile(path, extractDir);
-
- // 从解压目录加载插件
- // Load plugins from extracted directory
- List plugins = loadPluginsFromDirectory(extractDir, parentClassLoaderPackages);
-
- // 如果使用的是临时目录,则清理
- // Clean up if using temporary directory
- if (targetDir == null) {
- cleanupTempDirectory(extractDir);
- }
-
- return plugins;
- }
+ if (!isValidTarPath(path)) {
+ return List.of();
}
-
- return List.of();
+ if (extractDir == null) {
+ extractDir = createTempDirectory();
+ }
+ extractArchive(path, extractDir);
+ return loadPluginsFromDirectory(extractDir, parentClassLoaderPackages);
}
catch (Exception e) {
log.error("Failed to load plugins from tar file: {}", path, e);
return List.of();
}
+ finally {
+ if (targetDir == null && extractDir != null) {
+ cleanupTempDirectory(extractDir);
+ }
+ }
+ }
+
+ /** Extract without creating a class loader. The manager loads from the final install path. */
+ public void extractArchive(Path source, Path destination)
+ throws IOException
+ {
+ if (!isValidTarPath(source)) {
+ throw new IOException("Invalid plugin archive path");
+ }
+ Path archive = source;
+ boolean remote = isRemote(source);
+ if (remote) {
+ archive = downloadTarFile(source.toString().replaceFirst("^(https?):/+(?=[^/])", "$1://"));
+ }
+ try {
+ extractTarFile(archive, destination);
+ }
+ finally {
+ if (remote) {
+ Files.deleteIfExists(archive);
+ }
+ }
}
/**
@@ -217,24 +214,7 @@ private Path downloadTarFile(String url)
Path tempFile = Files.createTempFile(TEMP_DIR_PREFIX, ".tar");
try {
- // 创建信任所有证书的 SSL 上下文
- // Create SSL context that trusts all certificates
- SSLContext sslContext = SSLContextBuilder.create()
- .loadTrustMaterial(null, (cert, authType) -> true)
- .build();
-
- // 创建允许所有主机名的验证器
- // Create verifier that allows all hostnames
- SSLConnectionSocketFactory sslFactory = new SSLConnectionSocketFactory(
- sslContext,
- NoopHostnameVerifier.INSTANCE
- );
-
- // 创建 HttpClient
- // Create HttpClient
- try (CloseableHttpClient httpClient = HttpClients.custom()
- .setSSLSocketFactory(sslFactory)
- .build()) {
+ try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
// 创建 GET 请求
// Create GET request
HttpGet request = new HttpGet(url);
@@ -263,7 +243,7 @@ private Path downloadTarFile(String url)
return tempFile;
}
- catch (IOException | NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) {
+ catch (IOException e) {
try {
Files.deleteIfExists(tempFile);
}
@@ -298,17 +278,19 @@ private void extractTarFile(Path tarFile, Path destDir)
{
log.info("Extracting tar file to: {}", destDir);
- try (InputStream fileIn = Files.newInputStream(tarFile);
- BufferedInputStream buffIn = new BufferedInputStream(fileIn);
- GZIPInputStream gzipIn = new GZIPInputStream(buffIn);
- TarArchiveInputStream tarIn = new TarArchiveInputStream(gzipIn)) {
+ Path root = destDir.toAbsolutePath().normalize();
+ try (BufferedInputStream file = new BufferedInputStream(Files.newInputStream(tarFile));
+ TarArchiveInputStream tarIn = new TarArchiveInputStream(archiveStream(file))) {
TarArchiveEntry entry;
while ((entry = tarIn.getNextEntry()) != null) {
if (entry.isDirectory()) {
continue;
}
- Path outPath = destDir.resolve(entry.getName());
+ Path outPath = root.resolve(entry.getName()).normalize();
+ if (!outPath.startsWith(root) || !entry.isFile()) {
+ throw new IOException("Invalid plugin archive entry");
+ }
Files.createDirectories(outPath.getParent());
try (OutputStream out = Files.newOutputStream(outPath)) {
@@ -346,28 +328,22 @@ private void cleanupTempDirectory(Path directory)
private boolean isValidTarPath(Path path)
{
String pathStr = path.toString();
- return (pathStr.startsWith("http") || pathStr.startsWith("https"))
- && (pathStr.endsWith("tar.gz") || pathStr.endsWith("tar") || pathStr.endsWith("tgz"));
+ return (isRemote(path) || Files.isRegularFile(path))
+ && (pathStr.endsWith(".tar.gz") || pathStr.endsWith(".tgz") || pathStr.endsWith(".tar"));
}
- /**
- * 检查是否合法的 Tar 文件,通过解压文件头检查
- * Check if it's a valid Tar file, by checking the file header
- *
- * @param path file path
- * @return true if it's a valid Tar file
- */
- private boolean isValidTarFile(Path path)
+ private static boolean isRemote(Path path)
{
- try (InputStream fileIn = Files.newInputStream(path);
- BufferedInputStream buffIn = new BufferedInputStream(fileIn);
- GZIPInputStream gzipIn = new GZIPInputStream(buffIn)) {
- log.debug("Validating tar file: {}", path);
- log.debug("Tar file header: {}", gzipIn.readNBytes(512));
- return true;
- }
- catch (IOException e) {
- return false;
- }
+ String value = path.toString();
+ return value.startsWith("http:/") || value.startsWith("https:/");
+ }
+
+ private static InputStream archiveStream(BufferedInputStream file)
+ throws IOException
+ {
+ file.mark(2);
+ boolean compressed = file.read() == 0x1f && file.read() == 0x8b;
+ file.reset();
+ return compressed ? new GZIPInputStream(file) : file;
}
}
diff --git a/core/metaforge-plugin/src/test/java/org/devlive/metaforge/plugin/loader/CompiledPomPluginLoaderTest.java b/core/metaforge-plugin/src/test/java/org/devlive/metaforge/plugin/loader/CompiledPomPluginLoaderTest.java
index 5919b7b0b1..cf7b817770 100644
--- a/core/metaforge-plugin/src/test/java/org/devlive/metaforge/plugin/loader/CompiledPomPluginLoaderTest.java
+++ b/core/metaforge-plugin/src/test/java/org/devlive/metaforge/plugin/loader/CompiledPomPluginLoaderTest.java
@@ -15,6 +15,8 @@
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
public class CompiledPomPluginLoaderTest
{
@@ -50,6 +52,43 @@ public void compiledPluginsKeepTheirActualClassLoader()
plugin.getPluginClassLoader().close();
}
+ @Test
+ public void developmentModulesUseTheirDirectoryAsCatalogueKey()
+ throws Exception
+ {
+ // metaforge-fs-local registers as "LocalFs"; only the directory name matches the catalogue key.
+ Path root = module("LocalFsPlugin", "FILESYSTEM");
+ Path module = Files.move(root, root.resolveSibling("metaforge-fs-local"));
+ List loaded = PluginLoaderFactory.loadPlugins(module.resolve("pom.xml"), Set.of("java.", "org.devlive.metaforge.plugin."));
+ Assert.assertEquals(1, loaded.size());
+ Assert.assertEquals("metaforge-fs-local", loaded.get(0).getKey());
+ loaded.get(0).getPluginClassLoader().close();
+ }
+
+ @Test
+ public void everyPluginReadsTheStoreDescriptorShippedInItsOwnClasses()
+ throws Exception
+ {
+ Path first = module("FirstStorePlugin", "EXECUTOR");
+ Path second = module("SecondStorePlugin", "SCHEDULER");
+ Path descriptor = first.resolve("target/classes").resolve(org.devlive.metaforge.plugin.PluginDescriptor.FILE);
+ Files.createDirectories(first.resolve("target/classes/org/devlive/metaforge/plugin"));
+ Files.writeString(descriptor, "{\"label\":\"First\",\"features\":[\"cron\",7],\"order\":1,\"secret\":\"x\"}");
+ Path manifest = temporary.getRoot().toPath().resolve("store.properties");
+ Files.writeString(manifest, "first=" + first.resolve("pom.xml") + "\nsecond=" + second.resolve("pom.xml") + "\n");
+ PluginManager manager = new PluginManager(PluginConfigure.builder().pluginsDir(manifest).autoCleanup(false).build());
+ try {
+ manager.start();
+ java.util.Map> stores = new java.util.HashMap<>();
+ manager.getPluginInfos().forEach(plugin -> stores.put(plugin.getName(), plugin.getStore()));
+ Assert.assertEquals(java.util.Map.of("label", "First", "features", List.of("cron")), stores.get("FirstStore"));
+ Assert.assertEquals(java.util.Map.of(), stores.get("SecondStore"));
+ }
+ finally {
+ manager.stop();
+ }
+ }
+
@Test
public void aManifestRegistersAllCompiledExecutorsAndSchedulers()
throws Exception
@@ -69,4 +108,28 @@ public void aManifestRegistersAllCompiledExecutorsAndSchedulers()
manager.stop();
}
}
+
+ @Test
+ public void dependencyJarsCopiedByThePluginBuildAreOnTheClassPath()
+ throws Exception
+ {
+ // A plugin build copies its driver to target/dependencies; the plugin must load it from there.
+ Path root = module("DriverProbePlugin", "EXECUTOR");
+ Path sources = temporary.newFolder("driver-sources").toPath();
+ Path classes = temporary.newFolder("driver-classes").toPath();
+ Path source = sources.resolve("ProbeDriver.java");
+ Files.writeString(source, "package fixtures.driver; public class ProbeDriver {}");
+ Assert.assertEquals(0, ToolProvider.getSystemJavaCompiler().run(null, null, null, "-d", classes.toString(), source.toString()));
+ Path dependencies = Files.createDirectories(root.resolve("target/dependencies"));
+ try (JarOutputStream jar = new JarOutputStream(Files.newOutputStream(dependencies.resolve("probe-driver.jar")))) {
+ jar.putNextEntry(new JarEntry("fixtures/driver/ProbeDriver.class"));
+ jar.write(Files.readAllBytes(classes.resolve("fixtures/driver/ProbeDriver.class")));
+ jar.closeEntry();
+ }
+
+ Assert.assertEquals(List.of(dependencies.resolve("probe-driver.jar")), CompiledPomPluginLoader.dependencyJars(root));
+ Plugin plugin = new CompiledPomPluginLoader().load(root, Set.of("java.", "org.devlive.metaforge.plugin.")).get(0);
+ Assert.assertNotNull(plugin.getPluginClassLoader().loadClass("fixtures.driver.ProbeDriver"));
+ plugin.getPluginClassLoader().close();
+ }
}
diff --git a/core/metaforge-security/src/main/java/org/devlive/metaforge/security/SecurityConfigure.java b/core/metaforge-security/src/main/java/org/devlive/metaforge/security/SecurityConfigure.java
index aa90b41bbe..e00ce9c7d2 100644
--- a/core/metaforge-security/src/main/java/org/devlive/metaforge/security/SecurityConfigure.java
+++ b/core/metaforge-security/src/main/java/org/devlive/metaforge/security/SecurityConfigure.java
@@ -13,6 +13,10 @@
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.security.web.header.writers.DelegatingRequestMatcherHeaderWriter;
+import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.security.web.util.matcher.NegatedRequestMatcher;
@Configuration
@EnableWebSecurity
@@ -64,18 +68,28 @@ public PasswordEncoder passwordEncoder()
protected void configure(HttpSecurity http)
throws Exception
{
+ // /assets/** is the web UI's build output (scripts, styles, images); browsers fetch CSS images without a token.
http.cors().and().csrf().disable()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests()
- .antMatchers("/", "/**/*.js", "/**/*.css", "/api/auth/**", "/fonts/**", "/static/**", "/h2-console/**", "/api/v1/captcha", "/api/v1/table/dataDownload/**", "/upload/**", "/api/v1/setup/**")
+ .antMatchers("/", "/**/*.js", "/**/*.css", "/assets/**", "/api/auth/**", "/fonts/**", "/static/**", "/h2-console/**", "/api/v1/captcha", "/api/v1/table/dataDownload/**", "/upload/**", "/api/v1/setup/**",
+ // Shared reports: token-addressed public API and the pages that render it
+ "/api/v1/public/**", "/share/**", "/embed/**")
+ .permitAll()
+ // Pages of the history-routed web UI; their API calls stay authenticated
+ .requestMatchers(new WebUiRequestMatcher())
.permitAll()
.requestMatchers(new AdminApiRequestMatcher())
.hasAuthority("ADMIN")
.anyRequest()
.authenticated();
- // Fixed load denied by X-Frame-Options
- http.headers().frameOptions().sameOrigin();
+ // Same-origin framing everywhere except embedded report pages, whose CSP frame-ancestors
+ // carries the sites the report owner allowed.
+ http.headers().frameOptions().disable()
+ .addHeaderWriter(new DelegatingRequestMatcherHeaderWriter(
+ new NegatedRequestMatcher(new AntPathRequestMatcher("/embed/**")),
+ new XFrameOptionsHeaderWriter(XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)));
http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
diff --git a/core/metaforge-security/src/main/java/org/devlive/metaforge/security/WebUiRequestMatcher.java b/core/metaforge-security/src/main/java/org/devlive/metaforge/security/WebUiRequestMatcher.java
new file mode 100644
index 0000000000..137108b2ad
--- /dev/null
+++ b/core/metaforge-security/src/main/java/org/devlive/metaforge/security/WebUiRequestMatcher.java
@@ -0,0 +1,48 @@
+package org.devlive.metaforge.security;
+
+import org.springframework.security.web.util.matcher.RequestMatcher;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * A browser asking for a page of the web UI. The UI uses history routing (/query, /admin/user ...),
+ * so every such address returns the UI document and the app routes it; the page itself needs no
+ * sign-in, the APIs it calls do. API paths, static files and anything with a file extension are not
+ * pages.
+ */
+public class WebUiRequestMatcher
+ implements RequestMatcher
+{
+ private static final String[] NOT_PAGES = {"/api/", "/assets/", "/static/", "/fonts/", "/upload/", "/h2-console", "/share/", "/embed/"};
+
+ @Override
+ public boolean matches(HttpServletRequest request)
+ {
+ if (!"GET".equals(request.getMethod()) && !"HEAD".equals(request.getMethod())) {
+ return false;
+ }
+ return isPage(path(request));
+ }
+
+ public static boolean isPage(String path)
+ {
+ if (path == null || path.isEmpty() || !path.startsWith("/") || "/api".equals(path)) {
+ return false;
+ }
+ for (String prefix : NOT_PAGES) {
+ if (path.startsWith(prefix)) {
+ return false;
+ }
+ }
+ // Files carry an extension in their last segment (favicon.ico, robots.txt, index.html).
+ String last = path.substring(path.lastIndexOf('/') + 1);
+ return !last.contains(".");
+ }
+
+ static String path(HttpServletRequest request)
+ {
+ String uri = request.getRequestURI();
+ String context = request.getContextPath();
+ return context != null && !context.isEmpty() && uri.startsWith(context) ? uri.substring(context.length()) : uri;
+ }
+}
diff --git a/core/metaforge-server/pom.xml b/core/metaforge-server/pom.xml
index ea1309082e..46824f4086 100644
--- a/core/metaforge-server/pom.xml
+++ b/core/metaforge-server/pom.xml
@@ -201,6 +201,20 @@
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+ application.properties
+
+
+
com.github.eirslett
frontend-maven-plugin
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/CaptchaController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/CaptchaController.java
index 4f40d30048..4e5710c43e 100644
--- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/CaptchaController.java
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/CaptchaController.java
@@ -1,24 +1,29 @@
package org.devlive.metaforge.server.controller;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.commons.lang3.ObjectUtils;
import org.devlive.metaforge.captcha.CalculateUtils;
import org.devlive.metaforge.captcha.entity.ResultEntity;
import org.devlive.metaforge.common.response.CommonResponse;
import org.devlive.metaforge.service.initializer.InitializerConfigure;
+import org.devlive.metaforge.service.service.SystemSettingService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController()
+@SuppressFBWarnings(value = {"EI_EXPOSE_REP", "EI_EXPOSE_REP2"})
@RequestMapping(value = "/api/v1/captcha")
public class CaptchaController
{
private final InitializerConfigure initializer;
+ private final SystemSettingService settings;
- public CaptchaController(InitializerConfigure initializer)
+ public CaptchaController(InitializerConfigure initializer, SystemSettingService settings)
{
this.initializer = initializer;
+ this.settings = settings;
}
@GetMapping
@@ -28,7 +33,7 @@ public CommonResponse getCaptcha(@RequestParam(value = "timestamp") Long
return CommonResponse.failure("timestamp is required");
}
// If the verification code is disabled, directly skip the logic of obtaining the verification code
- if (!initializer.getCaptchaEnable()) {
+ if (!settings.auth().isCaptcha()) {
return CommonResponse.success(Boolean.FALSE);
}
ResultEntity entity = CalculateUtils.generate();
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/ConfigureController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/ConfigureController.java
deleted file mode 100644
index 19f5bf49a7..0000000000
--- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/ConfigureController.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package org.devlive.metaforge.server.controller;
-
-import org.devlive.metaforge.common.response.CommonResponse;
-import org.devlive.metaforge.service.itransient.configuration.Configuration;
-import org.devlive.metaforge.service.service.ConfigureService;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-
-@RestController()
-@RequestMapping(value = "/api/v1/configure")
-public class ConfigureController
-{
- private final ConfigureService service;
-
- public ConfigureController(ConfigureService service)
- {
- this.service = service;
- }
-
- @GetMapping(value = "/executor")
- public CommonResponse executor()
- {
- return service.getExecutor();
- }
-}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DashboardController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DashboardController.java
index 969a1b183c..6fa7af7af4 100644
--- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DashboardController.java
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DashboardController.java
@@ -1,11 +1,24 @@
package org.devlive.metaforge.server.controller;
+import org.devlive.metaforge.common.response.CommonResponse;
+import org.devlive.metaforge.service.body.DashboardShareBody;
import org.devlive.metaforge.service.entity.DashboardEntity;
import org.devlive.metaforge.service.repository.DashboardRepository;
import org.devlive.metaforge.service.service.DashboardService;
+import org.springframework.http.CacheControl;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import java.util.List;
+import java.util.Map;
+
@RestController()
@RequestMapping(value = "/api/v1/dashboard")
public class DashboardController
@@ -20,4 +33,52 @@ protected DashboardController(DashboardRepository repository, DashboardService s
this.repository = repository;
this.service = service;
}
+
+ /** Opens a published data screen of the current user as their home page. */
+ @PutMapping(value = "home/{code}")
+ public CommonResponse setHomePage(@PathVariable String code)
+ {
+ return service.setHomePage(code);
+ }
+
+ /** Restores the default home page. */
+ @DeleteMapping(value = "home")
+ public CommonResponse clearHomePage()
+ {
+ return service.setHomePage(null);
+ }
+
+ /** Screens other users share with the signed-in user. */
+ @GetMapping(value = "shared")
+ public CommonResponse> shared(@RequestParam(defaultValue = "") String keyword,
+ @RequestParam(defaultValue = "updated") String sort)
+ {
+ return service.shared(keyword, sort);
+ }
+
+ /** Users and roles to share with. */
+ @GetMapping(value = "share/candidates")
+ public CommonResponse> shareCandidates(@RequestParam(value = "keyword", required = false) String keyword)
+ {
+ return service.shareCandidates(keyword);
+ }
+
+ @GetMapping(value = "{code}/share")
+ public CommonResponse getShare(@PathVariable String code)
+ {
+ return service.getShare(code);
+ }
+
+ @PutMapping(value = "{code}/share")
+ public CommonResponse saveShare(@PathVariable String code, @RequestBody DashboardShareBody body)
+ {
+ return service.saveShare(code, body);
+ }
+
+ /** A dynamic chart of the screen, for anyone the screen is shared with. */
+ @GetMapping(value = "{code}/reports/{report}/refresh")
+ public ResponseEntity>>> refreshChart(@PathVariable String code, @PathVariable String report)
+ {
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(service.refreshChart(code, report));
+ }
}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DataSetController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DataSetController.java
index ea855647d7..8d2232d476 100644
--- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DataSetController.java
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DataSetController.java
@@ -6,6 +6,8 @@
import org.devlive.metaforge.service.body.FilterBody;
import org.devlive.metaforge.service.body.adhoc.Adhoc;
import org.devlive.metaforge.service.dataset.DatasetVirtualColumns;
+import org.devlive.metaforge.service.dataset.sync.SyncActiveView;
+import org.devlive.metaforge.service.dataset.sync.SyncMetricsView;
import org.devlive.metaforge.service.entity.DataSetColumnEntity;
import org.devlive.metaforge.service.entity.DataSetEntity;
import org.devlive.metaforge.service.entity.DatasetHistoryEntity;
@@ -50,6 +52,12 @@ protected DataSetController(DataSetRepository repository, DataSetService service
this.initializer = initializer;
}
+ @GetMapping(value = "count")
+ public CommonResponse count()
+ {
+ return service.count();
+ }
+
@PostMapping(value = "validateColumns")
public CommonResponse validateColumns(@RequestBody List columns)
{
@@ -102,7 +110,7 @@ public CommonResponse> getColumnsByCode(@PathVariable
}
@PutMapping(value = "syncData/{code}")
- public CommonResponse syncData(@PathVariable String code,
+ public CommonResponse syncData(@PathVariable String code,
@RequestBody(required = false) java.util.Map overrides)
{
return service.syncData(code, overrides);
@@ -146,6 +154,36 @@ public CommonResponse historyStop(@PathVariable Long id)
return this.service.stopHistory(id);
}
+ @GetMapping(value = "sync/active")
+ public CommonResponse> activeSyncs()
+ {
+ return this.service.getActiveSyncs();
+ }
+
+ @PutMapping(value = "history/pause/{id}")
+ public CommonResponse historyPause(@PathVariable Long id)
+ {
+ return this.service.pauseHistory(id);
+ }
+
+ @PutMapping(value = "history/resume/{id}")
+ public CommonResponse historyResume(@PathVariable Long id)
+ {
+ return this.service.resumeHistory(id);
+ }
+
+ @PutMapping(value = "history/retry/{id}")
+ public CommonResponse historyRetry(@PathVariable Long id)
+ {
+ return this.service.retryHistory(id);
+ }
+
+ @GetMapping(value = "history/metrics/{id}")
+ public CommonResponse historyMetrics(@PathVariable Long id)
+ {
+ return this.service.getHistoryMetrics(id);
+ }
+
@GetMapping(value = "getActuators")
public CommonResponse> getActuators()
{
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DatabaseExportController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DatabaseExportController.java
new file mode 100644
index 0000000000..c0956ad70b
--- /dev/null
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DatabaseExportController.java
@@ -0,0 +1,82 @@
+package org.devlive.metaforge.server.controller;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import org.devlive.metaforge.common.response.CommonResponse;
+import org.devlive.metaforge.service.exporter.ExportPlan;
+import org.devlive.metaforge.service.service.DatabaseExportService;
+import org.devlive.metaforge.service.service.TableExportService;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+/** Export of a database's tables: formats, samples, the plan check, the job and its file. */
+@RestController
+@RequestMapping(value = "/api/v1/export")
+@SuppressFBWarnings(value = {"EI_EXPOSE_REP2"})
+public class DatabaseExportController
+{
+ private final DatabaseExportService service;
+
+ public DatabaseExportController(DatabaseExportService service)
+ {
+ this.service = service;
+ }
+
+ @GetMapping(value = "formats")
+ public CommonResponse>> formats()
+ {
+ return this.service.formats();
+ }
+
+ @PostMapping(value = "{code}/{database}/sample")
+ public CommonResponse> sample(@PathVariable String code, @PathVariable String database, @RequestParam String table,
+ @RequestParam(defaultValue = "3") int rows, @RequestBody ExportPlan plan)
+ {
+ return this.service.sample(code, database, plan, table, rows);
+ }
+
+ @PostMapping(value = "{code}/{database}/check")
+ public CommonResponse> check(@PathVariable String code, @PathVariable String database, @RequestBody ExportPlan plan)
+ {
+ return this.service.check(code, database, plan);
+ }
+
+ @PostMapping(value = "{code}/{database}/execute")
+ public CommonResponse> execute(@PathVariable String code, @PathVariable String database, @RequestBody ExportPlan plan)
+ {
+ return this.service.execute(code, database, plan);
+ }
+
+ @GetMapping(value = "jobs/{id}")
+ public CommonResponse> job(@PathVariable String id)
+ {
+ return this.service.job(id);
+ }
+
+ @PostMapping(value = "jobs/{id}/file")
+ public ResponseEntity> file(@PathVariable String id)
+ {
+ CommonResponse response = this.service.file(id);
+ if (!Boolean.TRUE.equals(response.getStatus()) || response.getData() == null) {
+ return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
+ }
+ TableExportService.ExportFile file = response.getData();
+ return ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_OCTET_STREAM)
+ .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(file.getName(), StandardCharsets.UTF_8).build().toString())
+ .header(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.CONTENT_DISPOSITION)
+ .body(file.getContent());
+ }
+}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/NotificationController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/NotificationController.java
index 45e97f92e4..b4f78d6d47 100644
--- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/NotificationController.java
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/NotificationController.java
@@ -1,25 +1,101 @@
package org.devlive.metaforge.server.controller;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
-import org.devlive.metaforge.service.entity.NotificationEntity;
-import org.devlive.metaforge.service.repository.NotificationRepository;
-import org.devlive.metaforge.service.service.NotificationService;
+import lombok.Data;
+import org.devlive.metaforge.common.response.CommonResponse;
+import org.devlive.metaforge.service.enums.NotificationScene;
+import org.devlive.metaforge.service.notify.NotificationInbox;
+import org.devlive.metaforge.service.security.UserDetailsService;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
-@RestController()
+import java.util.Arrays;
+import java.util.Map;
+
+/** The signed-in user's notification center: list, counts, read state and removal of their own notifications. */
+@RestController
@RequestMapping(value = "/api/v1/notification")
@SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "Collaborators are Spring-managed singletons")
public class NotificationController
- extends BaseController
{
- private final NotificationService service;
- private final NotificationRepository repository;
+ private final NotificationInbox inbox;
+
+ public NotificationController(NotificationInbox inbox)
+ {
+ this.inbox = inbox;
+ }
+
+ @Data
+ public static class ReadRequest
+ {
+ private boolean read = true;
+ }
+
+ private static Long user()
+ {
+ return UserDetailsService.getUser().getId();
+ }
+
+ private static boolean knownCategory(String category)
+ {
+ return category == null || category.isBlank() || NotificationScene.SYSTEM.equals(category)
+ || Arrays.stream(NotificationScene.Category.values()).anyMatch(value -> value.name().equals(category));
+ }
+
+ @PostMapping(value = "list")
+ public CommonResponse list(@RequestBody NotificationInbox.Query query)
+ {
+ if (!knownCategory(query.getCategory())) {
+ return CommonResponse.failure("Unknown notification category: " + query.getCategory());
+ }
+ return CommonResponse.success(inbox.page(user(), query));
+ }
+
+ @GetMapping(value = "summary")
+ public CommonResponse summary()
+ {
+ return CommonResponse.success(inbox.summary(user()));
+ }
+
+ @GetMapping(value = "{code}")
+ public CommonResponse info(@PathVariable(value = "code") String code)
+ {
+ return inbox.find(user(), code)
+ .map(CommonResponse::success)
+ .orElseGet(() -> CommonResponse.failure("Notification not found"));
+ }
+
+ @PutMapping(value = "{code}/read")
+ public CommonResponse mark(@PathVariable(value = "code") String code, @RequestBody ReadRequest request)
+ {
+ return inbox.mark(user(), code, request.isRead()) ? CommonResponse.success(true) : CommonResponse.failure("Notification not found");
+ }
+
+ @PutMapping(value = "read-all")
+ public CommonResponse> markAllRead(@RequestParam(value = "category", required = false) String category)
+ {
+ if (!knownCategory(category)) {
+ return CommonResponse.failure("Unknown notification category: " + category);
+ }
+ return CommonResponse.success(Map.of("updated", inbox.markAllRead(user(), category)));
+ }
+
+ @DeleteMapping(value = "{code}")
+ public CommonResponse delete(@PathVariable(value = "code") String code)
+ {
+ return inbox.delete(user(), code) ? CommonResponse.success(true) : CommonResponse.failure("Notification not found");
+ }
- public NotificationController(NotificationService service, NotificationRepository repository)
+ @DeleteMapping(value = "read")
+ public CommonResponse> deleteRead()
{
- super(repository, service);
- this.service = service;
- this.repository = repository;
+ return CommonResponse.success(Map.of("deleted", inbox.deleteRead(user())));
}
}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PluginController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PluginController.java
index e2d4db825f..c4970017ae 100644
--- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PluginController.java
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PluginController.java
@@ -1,10 +1,17 @@
package org.devlive.metaforge.server.controller;
+import com.fasterxml.jackson.databind.JsonNode;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
import org.devlive.metaforge.common.response.CommonResponse;
import org.devlive.metaforge.plugin.PluginManager;
import org.devlive.metaforge.plugin.PluginMetadata;
+import org.devlive.metaforge.server.service.PluginReferences;
+import org.devlive.metaforge.server.service.PluginStore;
+import org.devlive.metaforge.service.enums.EntityType;
+import org.devlive.metaforge.service.enums.NotificationScene;
+import org.devlive.metaforge.service.event.NotificationEventPublisher;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -15,19 +22,39 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import java.io.IOException;
import java.nio.file.Path;
+import java.util.Arrays;
import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+@Slf4j
@RestController
@RequestMapping(value = "/api/v1/plugin")
@SuppressFBWarnings(value = {"EI_EXPOSE_REP2"})
public class PluginController
{
private final PluginManager pluginManager;
+ private final PluginReferences references;
+ private final PluginStore store;
+ private final NotificationEventPublisher notifications;
- public PluginController(PluginManager pluginManager)
+ public PluginController(PluginManager pluginManager, PluginReferences references, PluginStore store, NotificationEventPublisher notifications)
{
this.pluginManager = pluginManager;
+ this.references = references;
+ this.store = store;
+ this.notifications = notifications;
+ }
+
+ /** Tells the administrator how a plugin change went, once it went through. */
+ private CommonResponse notified(CommonResponse response, NotificationScene scene, String name, String title, String content)
+ {
+ if (Boolean.TRUE.equals(response.getStatus())) {
+ notifications.publish(scene, title, content, EntityType.PLUGIN, name, name);
+ }
+ return response;
}
@GetMapping
@@ -43,14 +70,93 @@ public CommonResponse> getPlugins(@RequestParam(value = "ha
@PostMapping(value = "install")
public CommonResponse installPlugin(@RequestBody PluginInstallRequest request)
{
- return CommonResponse.success(pluginManager.installPlugin(Path.of(request.url), request.name));
+ if (request.getName() == null || request.getName().isBlank()) {
+ return CommonResponse.failure("Plugin package and name are required");
+ }
+ // 带版本的商店安装:经统计服务按配置来源下载、校验 SHA-256/大小后安装并回报下载量;
+ // 仅有 url 的旧请求保持原行为
+ // Store installs with a version download through the statistics service from the configured
+ // source, verify SHA-256 and size, then report the download. Requests with only a url keep
+ // the original behaviour.
+ if (request.getVersion() != null && !request.getVersion().isBlank()) {
+ PluginStore.Package target = PluginStore.Package.builder()
+ .key(request.getName())
+ .version(request.getVersion())
+ .url(request.getUrl())
+ .sha256(request.getSha256())
+ .size(request.getSize())
+ .build();
+ try {
+ return notified(store.install(target, (archive, name) -> pluginManager.installPlugin(archive, name))
+ ? CommonResponse.success(true) : CommonResponse.failure("Unable to install the plugin package"),
+ NotificationScene.PLUGIN_INSTALLED, request.getName(), String.format("插件 %s 已安装", request.getName()),
+ String.format("插件 %s %s 已从插件商店安装。", request.getName(), request.getVersion()));
+ }
+ catch (IOException e) {
+ log.warn("Store install of {} {} failed: {}", request.getName(), request.getVersion(), e.getMessage());
+ return CommonResponse.failure(e.getMessage());
+ }
+ }
+ if (request.getUrl() == null || request.getUrl().isBlank()) {
+ return CommonResponse.failure("Plugin package and name are required");
+ }
+ return notified(pluginManager.installPlugin(Path.of(request.getUrl()), request.getName())
+ ? CommonResponse.success(true) : CommonResponse.failure("Unable to install the plugin package"),
+ NotificationScene.PLUGIN_INSTALLED, request.getName(), String.format("插件 %s 已安装", request.getName()),
+ String.format("插件 %s 已安装。", request.getName()));
+ }
+
+ // 商店目录由服务端拉取远端清单后返回,浏览器不做跨域请求;拉取失败时前端回退到内置目录
+ // The store catalogue is fetched server-side so the browser makes no cross-origin request;
+ // on failure the frontend falls back to its bundled catalogue
+ @GetMapping(value = "catalogue")
+ public CommonResponse catalogue()
+ {
+ JsonNode catalogue = store.catalogue();
+ return catalogue == null ? CommonResponse.failure("The plugin catalogue is unavailable") : CommonResponse.success(catalogue);
+ }
+
+ // 商店下载量来自统计服务,未知插件不返回(前端显示为未知而不是 0)
+ // Store download counts from the statistics service; unknown plugins are absent, never zero
+ @GetMapping(value = "downloads")
+ public CommonResponse> downloads(@RequestParam(value = "keys", required = false, defaultValue = "") String keys)
+ {
+ List list = Arrays.stream(keys.split(","))
+ .map(String::trim)
+ .filter(key -> !key.isEmpty())
+ .distinct()
+ .limit(500)
+ .collect(Collectors.toList());
+ return CommonResponse.success(store.downloads(list));
}
@PreAuthorize(value = "hasAuthority('ADMIN')")
@DeleteMapping(value = "uninstall/{name}")
public CommonResponse uninstallPlugin(@PathVariable(value = "name") String name)
{
- return CommonResponse.success(pluginManager.uninstallPlugin(name));
+ if (references.isInUse(name, pluginManager.getPluginInfos())) {
+ return CommonResponse.failure("The plugin is referenced by existing resources");
+ }
+ return notified(pluginManager.uninstallPlugin(name) ? CommonResponse.success(true) : CommonResponse.failure("Unable to uninstall the plugin"),
+ NotificationScene.PLUGIN_UNINSTALLED, name, String.format("插件 %s 已卸载", name), String.format("插件 %s 已卸载。", name));
+ }
+
+ @PreAuthorize(value = "hasAuthority('ADMIN')")
+ @GetMapping(value = "references")
+ public CommonResponse>> listReferences()
+ {
+ return CommonResponse.success(references.list(pluginManager.getPluginInfos()));
+ }
+
+ // 启用/禁用同样是改变运行中 JVM 的插件生命周期,必须仅限管理员
+ @PreAuthorize(value = "hasAuthority('ADMIN')")
+ @PostMapping(value = "/{name}/state")
+ public CommonResponse changeState(@PathVariable(value = "name") String name,
+ @RequestBody PluginStateRequest request)
+ {
+ String state = request.isEnabled() ? "启用" : "停用";
+ return notified(pluginManager.setPluginState(name, request.isEnabled()) ? CommonResponse.success(true) : CommonResponse.failure("Unable to change plugin state"),
+ NotificationScene.PLUGIN_STATE_CHANGED, name, String.format("插件 %s 已%s", name, state), String.format("插件 %s 已%s。", name, state));
}
@Data
@@ -60,5 +166,16 @@ public static class PluginInstallRequest
// 插件名称,默认作为插件的安装目录
// Plugin name, default as the installation directory
private String name;
+ // 商店目录中的版本、SHA-256 与大小(字节);有版本时走统计服务下载并校验
+ // Version, SHA-256 and size (bytes) from the store catalogue; a version selects the verified store download
+ private String version;
+ private String sha256;
+ private Long size;
+ }
+
+ @Data
+ public static class PluginStateRequest
+ {
+ private boolean enabled;
}
}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PublicReportController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PublicReportController.java
new file mode 100644
index 0000000000..12fbb3f9de
--- /dev/null
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PublicReportController.java
@@ -0,0 +1,100 @@
+package org.devlive.metaforge.server.controller;
+
+import org.devlive.metaforge.common.response.CommonResponse;
+import org.devlive.metaforge.service.service.ReportShareService;
+import org.springframework.http.CacheControl;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Anonymous access to shared reports by their share token. Only the saved report definition is
+ * evaluated; visitors cannot send SQL, queries or report codes.
+ */
+@RestController
+@RequestMapping(value = "/api/v1/public/report")
+public class PublicReportController
+{
+ private final ReportShareService service;
+
+ public PublicReportController(ReportShareService service)
+ {
+ this.service = service;
+ }
+
+ @GetMapping("{token}")
+ public ResponseEntity> view(@PathVariable("token") String token)
+ {
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(service.view(token));
+ }
+
+ @GetMapping("{token}/refresh")
+ public ResponseEntity>>> refresh(@PathVariable("token") String token)
+ {
+ return ResponseEntity.ok().cacheControl(CacheControl.maxAge(Duration.ofSeconds(30))).body(service.refresh(token));
+ }
+
+ @GetMapping("{token}/table")
+ public ResponseEntity> table(@PathVariable("token") String token)
+ {
+ return ResponseEntity.ok().cacheControl(CacheControl.maxAge(Duration.ofSeconds(30))).body(service.table(token));
+ }
+
+ @GetMapping("{token}/download")
+ public ResponseEntity download(@PathVariable("token") String token)
+ {
+ return service.download(token)
+ .map(entry -> ResponseEntity.ok()
+ .cacheControl(CacheControl.noStore())
+ .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
+ .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
+ .filename(entry.getKey() + ".csv", StandardCharsets.UTF_8).build().toString())
+ .body(csv(entry.getValue())))
+ .orElseGet(() -> ResponseEntity.notFound().build());
+ }
+
+ /** UTF-8 CSV with a byte order mark for spreadsheet apps; cells that would start a formula are quoted as text. */
+ static byte[] csv(ReportShareService.Table table)
+ {
+ StringBuilder out = new StringBuilder("\uFEFF");
+ line(out, new ArrayList<>(table.getHeaders()));
+ table.getRows().forEach(row -> line(out, row));
+ return out.toString().getBytes(StandardCharsets.UTF_8);
+ }
+
+ private static void line(StringBuilder out, List> cells)
+ {
+ for (int index = 0; index < cells.size(); index++) {
+ if (index > 0) {
+ out.append(',');
+ }
+ Object value = cells.get(index);
+ String cell = value == null ? "" : String.valueOf(value);
+ if (!(value instanceof Number) && !cell.isEmpty() && "=+-@\t\r".indexOf(cell.charAt(0)) >= 0) {
+ cell = "'" + cell;
+ }
+ if (cell.contains(",") || cell.contains("\"") || cell.contains("\n") || cell.contains("\r")) {
+ cell = "\"" + cell.replace("\"", "\"\"") + "\"";
+ }
+ out.append(cell);
+ }
+ out.append("\r\n");
+ }
+
+ @GetMapping("{token}/data")
+ public ResponseEntity> data(@PathVariable("token") String token)
+ {
+ return ResponseEntity.ok().cacheControl(CacheControl.maxAge(Duration.ofSeconds(30))).body(service.data(token));
+ }
+}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PublicScreenController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PublicScreenController.java
new file mode 100644
index 0000000000..58c1f61c28
--- /dev/null
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PublicScreenController.java
@@ -0,0 +1,43 @@
+package org.devlive.metaforge.server.controller;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import org.devlive.metaforge.common.response.CommonResponse;
+import org.devlive.metaforge.service.service.DashboardService;
+import org.springframework.http.CacheControl;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Anonymous access to a data screen through its public link: the published screen limited to the
+ * widgets the owner chose, and refreshes of its dynamic charts, which run as the owner.
+ */
+@RestController
+@RequestMapping(value = "/api/v1/public/screen")
+public class PublicScreenController
+{
+ private final DashboardService service;
+
+ @SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "Spring-managed services are intentionally shared singletons")
+ public PublicScreenController(DashboardService service)
+ {
+ this.service = service;
+ }
+
+ @GetMapping("{token}")
+ public ResponseEntity>> view(@PathVariable("token") String token)
+ {
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(service.publicView(token));
+ }
+
+ @GetMapping("{token}/reports/{report}/refresh")
+ public ResponseEntity>>> refresh(@PathVariable("token") String token, @PathVariable("report") String report)
+ {
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(service.publicRefresh(token, report));
+ }
+}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/ReportController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/ReportController.java
index e4bfacc833..23abeb1620 100644
--- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/ReportController.java
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/ReportController.java
@@ -1,23 +1,49 @@
package org.devlive.metaforge.server.controller;
+import org.devlive.metaforge.common.response.CommonResponse;
import org.devlive.metaforge.service.entity.ReportEntity;
import org.devlive.metaforge.service.repository.ReportRepository;
import org.devlive.metaforge.service.service.ReportService;
+import org.devlive.metaforge.service.service.ReportShareService;
+import org.springframework.http.CacheControl;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import java.util.List;
+import java.util.Map;
+
@RestController()
@RequestMapping(value = "/api/v1/report")
public class ReportController
extends BaseController
{
- private final ReportRepository repository;
private final ReportService service;
+ private final ReportShareService shareService;
- protected ReportController(ReportRepository repository, ReportService service)
+ protected ReportController(ReportRepository repository, ReportService service, ReportShareService shareService)
{
super(repository, service);
- this.repository = repository;
this.service = service;
+ this.shareService = shareService;
+ }
+
+ // Owner only: public link, iframe embedding, allowed sites and link rotation
+ @PutMapping("{code}/share")
+ public CommonResponse share(@PathVariable("code") String code, @RequestBody ReportShareService.ShareRequest request)
+ {
+ return shareService.share(code, request);
+ }
+
+ @GetMapping("{code}/chart-refresh")
+ public ResponseEntity>>> refreshChart(@PathVariable("code") String code)
+ {
+ return ResponseEntity.ok()
+ .cacheControl(CacheControl.noStore())
+ .body(service.refreshChart(code));
}
}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/SharedReportPageController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/SharedReportPageController.java
new file mode 100644
index 0000000000..b77737688a
--- /dev/null
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/SharedReportPageController.java
@@ -0,0 +1,73 @@
+package org.devlive.metaforge.server.controller;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import org.devlive.metaforge.server.web.WebUiDocument;
+import org.devlive.metaforge.service.service.DashboardService;
+import org.devlive.metaforge.service.service.ReportShareService;
+import org.springframework.http.CacheControl;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Serves the web UI document for shared reports on real paths, so each response can carry its own
+ * framing policy: /share/report/{token} stays same-origin only, /embed/report/{token} may be framed
+ * by the sites the owner allowed (CSP frame-ancestors). The web app routes both to the public view.
+ */
+@Controller
+public class SharedReportPageController
+{
+ private final ReportShareService service;
+ private final DashboardService screens;
+ private final WebUiDocument document;
+
+ @SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "Spring-managed services are intentionally shared singletons")
+ public SharedReportPageController(ReportShareService service, DashboardService screens, WebUiDocument document)
+ {
+ this.service = service;
+ this.screens = screens;
+ this.document = document;
+ }
+
+ @GetMapping("/share/screen/{token}")
+ public ResponseEntity shareScreen(@PathVariable("token") String token)
+ {
+ return page(HttpStatus.OK, "frame-ancestors 'self'");
+ }
+
+ @GetMapping("/embed/screen/{token}")
+ public ResponseEntity embedScreen(@PathVariable("token") String token)
+ {
+ // Unknown, private or non-embeddable screens render the "not available" view and refuse framing.
+ return page(HttpStatus.OK, "frame-ancestors " + screens.publicFrameAncestors(token).orElse("'none'"));
+ }
+
+ @GetMapping("/share/report/{token}")
+ public ResponseEntity share(@PathVariable("token") String token)
+ {
+ return page(HttpStatus.OK, "frame-ancestors 'self'");
+ }
+
+ @GetMapping("/embed/report/{token}")
+ public ResponseEntity embed(@PathVariable("token") String token)
+ {
+ // Unknown, private or non-embeddable reports render the "not available" view and refuse framing.
+ return page(HttpStatus.OK, "frame-ancestors " + service.frameAncestors(token).orElse("'none'"));
+ }
+
+ private ResponseEntity page(HttpStatus status, String frameAncestors)
+ {
+ return document.html()
+ .map(html -> ResponseEntity.status(status)
+ .contentType(new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8))
+ .cacheControl(CacheControl.noStore())
+ .header("Content-Security-Policy", frameAncestors)
+ .body(html))
+ .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).contentType(MediaType.TEXT_PLAIN).body("The web UI is not packaged with this server"));
+ }
+}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/SystemSettingController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/SystemSettingController.java
new file mode 100644
index 0000000000..a8369f9c9a
--- /dev/null
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/SystemSettingController.java
@@ -0,0 +1,95 @@
+package org.devlive.metaforge.server.controller;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.devlive.metaforge.common.response.CommonResponse;
+import org.devlive.metaforge.service.service.SystemSettingService;
+import org.devlive.metaforge.service.settings.AuthSettings;
+import org.devlive.metaforge.service.settings.FsSettings;
+import org.devlive.metaforge.service.settings.MailSettings;
+import org.devlive.metaforge.service.settings.OAuthSettings;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/** System settings, for administrators only (the /api/v1/admin/** guard). */
+@RestController
+@SuppressFBWarnings(value = {"EI_EXPOSE_REP", "EI_EXPOSE_REP2"})
+@RequestMapping(value = "/api/v1/admin/settings")
+public class SystemSettingController
+{
+ private final SystemSettingService service;
+
+ public SystemSettingController(SystemSettingService service)
+ {
+ this.service = service;
+ }
+
+ @GetMapping
+ public CommonResponse> settings()
+ {
+ return CommonResponse.success(service.settings());
+ }
+
+ @PutMapping
+ public CommonResponse> save(@RequestBody SettingsBody body)
+ {
+ return service.save(body.getAuth(), body.getMail(), body.getOauth(), body.getFs());
+ }
+
+ @PostMapping(value = "mail/test")
+ public CommonResponse testMail(@RequestBody MailTestBody body)
+ {
+ return service.testMail(body.getMail(), body.getTo());
+ }
+
+ /** Writes and deletes a probe file with the storage as it is in the form. */
+ @PostMapping(value = "fs/test")
+ public CommonResponse testFs(@RequestBody FsSettings fs)
+ {
+ return service.testFs(fs);
+ }
+
+ /** Tries an executor's or the dataset target's runtime values as they are in the form. */
+ @PostMapping(value = "runtime/test")
+ public CommonResponse testRuntime(@RequestBody RuntimeTestBody body)
+ {
+ return service.testRuntime(body.getCategory(), body.getName(), body.getValues());
+ }
+
+ @Data
+ @NoArgsConstructor
+ public static class RuntimeTestBody
+ {
+ private String category;
+ private String name;
+ private Map values;
+ }
+
+ @Data
+ @NoArgsConstructor
+ public static class SettingsBody
+ {
+ private AuthSettings auth;
+ private MailSettings mail;
+ /** Third-party sign-in providers by id; left out to keep them as they are. */
+ private Map oauth;
+ /** File storage; left out to keep it as it is. */
+ private FsSettings fs;
+ }
+
+ /** Tests the server as it is in the form, before it is saved. */
+ @Data
+ @NoArgsConstructor
+ public static class MailTestBody
+ {
+ private MailSettings mail;
+ private String to;
+ }
+}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/WorkflowController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/WorkflowController.java
index 9eb07b7697..6d88db6264 100644
--- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/WorkflowController.java
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/WorkflowController.java
@@ -4,13 +4,17 @@
import org.devlive.metaforge.service.entity.WorkflowEntity;
import org.devlive.metaforge.service.repository.WorkflowRepository;
import org.devlive.metaforge.service.service.WorkflowService;
+import org.devlive.metaforge.service.workflow.WorkflowRun;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
+import java.util.Map;
@RestController
@RequestMapping(value = "/api/v1/workflow")
@@ -31,6 +35,42 @@ public CommonResponse> log(@PathVariable String code)
return service.log(code);
}
+ @GetMapping(value = "/catalog")
+ public CommonResponse> catalog()
+ {
+ return service.catalog();
+ }
+
+ @GetMapping(value = "/statistics")
+ public CommonResponse> statistics()
+ {
+ return service.statistics();
+ }
+
+ @PostMapping(value = "/validate")
+ public CommonResponse> validate(@RequestBody WorkflowEntity workflow)
+ {
+ return service.validate(workflow);
+ }
+
+ @GetMapping(value = "/runs/{code}")
+ public CommonResponse> runs(@PathVariable String code)
+ {
+ return service.runs(code);
+ }
+
+ @GetMapping(value = "/run/{code}/{runId}")
+ public CommonResponse run(@PathVariable String code, @PathVariable String runId)
+ {
+ return service.run(code, runId);
+ }
+
+ @GetMapping(value = "/log/{code}/{runId}")
+ public CommonResponse> runLog(@PathVariable String code, @PathVariable String runId)
+ {
+ return service.log(code, runId);
+ }
+
@PutMapping(value = "stop/{code}")
public CommonResponse stop(@PathVariable String code)
{
@@ -42,4 +82,22 @@ public CommonResponse restart(@PathVariable String code)
{
return service.restart(code);
}
+
+ @PostMapping(value = "query-columns")
+ public CommonResponse>> queryColumns(@RequestBody Map body)
+ {
+ return service.queryColumns(body.get("source"), body.get("query"));
+ }
+
+ @PostMapping(value = "query-preview")
+ public CommonResponse> queryPreview(@RequestBody Map body)
+ {
+ return service.queryPreview(body.get("source"), body.get("query"));
+ }
+
+ @PutMapping(value = "publish/{code}")
+ public CommonResponse publish(@PathVariable String code)
+ {
+ return service.publish(code);
+ }
}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/AuthController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/AuthController.java
index d8c77b129b..2e9fcaf4ba 100644
--- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/AuthController.java
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/AuthController.java
@@ -1,88 +1,127 @@
package org.devlive.metaforge.server.controller.user;
import com.fasterxml.jackson.annotation.JsonView;
-import org.apache.commons.lang3.ObjectUtils;
-import org.devlive.metaforge.captcha.entity.ResultEntity;
import org.devlive.metaforge.common.response.CommonResponse;
import org.devlive.metaforge.common.view.EntityView;
import org.devlive.metaforge.service.body.AuthBody;
-import org.devlive.metaforge.service.entity.UserEntity;
-import org.devlive.metaforge.service.initializer.InitializerConfigure;
-import org.devlive.metaforge.service.service.UserService;
+import org.devlive.metaforge.service.body.EmailCodeBody;
+import org.devlive.metaforge.service.body.PasswordResetBody;
+import org.devlive.metaforge.service.service.AuthService;
+import org.devlive.metaforge.service.service.OAuthService;
import org.devlive.metaforge.service.validation.ValidationGroup;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.CookieValue;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController
{
- private final UserService userService;
- private final InitializerConfigure initializer;
+ /** Keeps the state of a third-party sign-in in the browser that started it. */
+ private static final String STATE_COOKIE = "metaforge_oauth_state";
- public AuthController(UserService userService, InitializerConfigure initializer)
+ private final AuthService authService;
+ private final OAuthService oauthService;
+
+ public AuthController(AuthService authService, OAuthService oauthService)
{
- this.userService = userService;
- this.initializer = initializer;
+ this.authService = authService;
+ this.oauthService = oauthService;
}
- /**
- * Validates the captcha.
- *
- * @param configure the authentication body containing captcha information
- * @return the common response object representing the result of the validation
- */
- private CommonResponse validateCaptcha(AuthBody configure)
+ /** What the sign-in and sign-up pages offer, as set in system settings. */
+ @GetMapping("/options")
+ public CommonResponse> options()
{
- if (initializer.getCaptchaEnable()) {
- if (ObjectUtils.isEmpty(configure.getCaptcha())) {
- return CommonResponse.failure("Captcha must not be empty");
- }
- ResultEntity entity = initializer.getCache().getIfPresent(configure.getTimestamp());
- if (entity == null) {
- return CommonResponse.failure("Captcha does not exists");
- }
- if (!entity.getResult().equals(configure.getCaptcha())) {
- initializer.getCache().invalidate(configure.getTimestamp());
- return CommonResponse.failure("The calculation result is incorrect");
- }
- }
- return null;
+ return CommonResponse.success(authService.options());
}
@PostMapping("/signin")
public CommonResponse authenticateUser(@RequestBody @Validated(ValidationGroup.Crud.Auth.class) AuthBody configure)
{
- CommonResponse captchaResponse = validateCaptcha(configure);
- if (captchaResponse != null) {
- return captchaResponse;
- }
-
- UserEntity user = new UserEntity();
- user.setUsername(configure.getUsername());
- user.setPassword(configure.getPassword());
- return this.userService.authenticate(user);
+ return authService.signin(configure);
}
@PostMapping("/signup")
@JsonView(value = {EntityView.UserView.class})
public CommonResponse> registerUser(@RequestBody @Validated(ValidationGroup.Crud.Create.class) AuthBody configure)
{
- if (!initializer.getRegistrationEnable()) {
- return CommonResponse.failure("Registration is disabled");
- }
+ return authService.signup(configure);
+ }
+
+ @PostMapping("/code")
+ public CommonResponse sendCode(@RequestBody EmailCodeBody configure)
+ {
+ return authService.sendCode(configure);
+ }
+
+ @PostMapping("/reset")
+ public CommonResponse resetPassword(@RequestBody PasswordResetBody configure)
+ {
+ return authService.reset(configure);
+ }
- CommonResponse> captchaResponse = validateCaptcha(configure);
- if (captchaResponse != null) {
- return captchaResponse;
+ /** Sends the browser to the provider's sign-in page, or back to the sign-in page when the provider is off. */
+ @GetMapping("/oauth/{provider}/authorize")
+ public void authorize(@PathVariable String provider, @RequestParam(required = false) String redirect, HttpServletRequest request, HttpServletResponse response)
+ throws IOException
+ {
+ OAuthService.Redirect target = oauthService.authorize(provider, redirect, base());
+ if (target.getState() != null) {
+ response.addHeader(HttpHeaders.SET_COOKIE, stateCookie(request, target.getState(), Duration.ofMinutes(10)));
}
+ response.sendRedirect(target.getLocation());
+ }
- UserEntity user = new UserEntity();
- user.setUsername(configure.getUsername());
- user.setPassword(configure.getPassword());
- return this.userService.saveOrUpdate(user);
+ /** Where the provider sends the browser back; it goes on to the sign-in callback page. */
+ @GetMapping("/oauth/{provider}/callback")
+ public void callback(@PathVariable String provider, @RequestParam(required = false) String code, @RequestParam(required = false) String state,
+ @RequestParam(required = false) String error, @CookieValue(name = STATE_COOKIE, required = false) String browserState,
+ HttpServletRequest request, HttpServletResponse response)
+ throws IOException
+ {
+ response.addHeader(HttpHeaders.SET_COOKIE, stateCookie(request, "", Duration.ZERO));
+ response.sendRedirect(oauthService.callback(provider, code, state, browserState, error, base()));
+ }
+
+ /** The token of a finished third-party sign-in, for the ticket the callback page was given. */
+ @PostMapping("/oauth/ticket")
+ public CommonResponse ticket(@RequestBody Map body)
+ {
+ return oauthService.redeem(body == null ? null : body.get("ticket"));
+ }
+
+ private static String base()
+ {
+ return ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString();
+ }
+
+ private static String stateCookie(HttpServletRequest request, String value, Duration age)
+ {
+ // Lax, so it comes along when the provider sends the browser back.
+ return ResponseCookie.from(STATE_COOKIE, value)
+ .httpOnly(true)
+ .secure(request.isSecure())
+ .sameSite("Lax")
+ .path(request.getContextPath() + "/api/auth/oauth")
+ .maxAge(age)
+ .build()
+ .toString();
}
}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/NotifySettingsController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/NotifySettingsController.java
new file mode 100644
index 0000000000..091ba62a2d
--- /dev/null
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/NotifySettingsController.java
@@ -0,0 +1,52 @@
+package org.devlive.metaforge.server.controller.user;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import org.devlive.metaforge.common.response.CommonResponse;
+import org.devlive.metaforge.service.notify.UserNotifySettings;
+import org.devlive.metaforge.service.security.UserDetailsService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/** The signed-in user's own notification channels, their settings and subscribed scenes. */
+@RestController
+@RequestMapping(value = "/api/v1/user/notify")
+@SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "The settings service is a Spring-managed singleton")
+public class NotifySettingsController
+{
+ private final UserNotifySettings settings;
+
+ public NotifySettingsController(UserNotifySettings settings)
+ {
+ this.settings = settings;
+ }
+
+ @GetMapping(value = "catalog")
+ public CommonResponse catalog()
+ {
+ return CommonResponse.success(settings.catalog(UserDetailsService.getUser()));
+ }
+
+ @GetMapping
+ public CommonResponse> settings()
+ {
+ return settings.settings(UserDetailsService.getUser());
+ }
+
+ @PutMapping
+ public CommonResponse> save(@RequestBody List configure)
+ {
+ return settings.save(UserDetailsService.getUser(), configure);
+ }
+
+ @PostMapping(value = "test")
+ public CommonResponse test(@RequestBody UserNotifySettings.ChannelSetting configure)
+ {
+ return settings.test(UserDetailsService.getUser(), configure);
+ }
+}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/PluginAuditAdminController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/PluginAuditAdminController.java
index a1ef546cc6..3c7a42629b 100644
--- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/PluginAuditAdminController.java
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/PluginAuditAdminController.java
@@ -2,10 +2,12 @@
import org.devlive.metaforge.common.response.CommonResponse;
import org.devlive.metaforge.service.activity.HeatmapActivity;
+import org.devlive.metaforge.service.activity.QueryActivity;
import org.devlive.metaforge.service.itransient.ContributionRadar;
import org.devlive.metaforge.service.service.PluginAuditService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@@ -27,6 +29,12 @@ public CommonResponse count()
return this.pluginAuditService.count();
}
+ @GetMapping(value = "activity")
+ public CommonResponse activity(@RequestParam(defaultValue = "7") int days)
+ {
+ return this.pluginAuditService.getHomeActivity(days);
+ }
+
@GetMapping(value = "contribution")
public CommonResponse> contribution()
{
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/UserController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/UserController.java
index 79f149ce3e..94e449a61a 100644
--- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/UserController.java
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/user/UserController.java
@@ -1,6 +1,7 @@
package org.devlive.metaforge.server.controller.user;
import com.google.common.collect.Sets;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.devlive.metaforge.common.response.CommonResponse;
@@ -49,6 +50,7 @@ public class UserController
private final UserLogService userLogService;
private final RoleRepository roleRepository;
+ @SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "Spring-managed repositories and services are intentionally shared singletons")
public UserController(UserRepository repository, UserService service, UserLogService userLogService, RoleRepository roleRepository)
{
super(repository, service);
@@ -145,10 +147,4 @@ public CommonResponse uploadAvatar(@ModelAttribute UploadBody conf
{
return this.service.uploadAvatar(configure);
}
-
- @PutMapping(value = "change-notify")
- public CommonResponse changeNotify(@RequestBody UserEntity configure)
- {
- return this.service.changeNotify(configure);
- }
}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/DatasetHistoryRecoveryRunner.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/DatasetHistoryRecoveryRunner.java
index 1404bfaa27..9147635486 100644
--- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/DatasetHistoryRecoveryRunner.java
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/DatasetHistoryRecoveryRunner.java
@@ -4,6 +4,7 @@
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.time.DateFormatUtils;
import org.devlive.metaforge.executor.common.RunState;
+import org.devlive.metaforge.executor.common.RunStateMachine;
import org.devlive.metaforge.service.entity.DatasetHistoryEntity;
import org.devlive.metaforge.service.initializer.InstallationState;
import org.devlive.metaforge.service.repository.DatasetHistoryRepository;
@@ -11,13 +12,12 @@
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Service;
-import java.util.Arrays;
import java.util.Date;
import java.util.List;
/**
* 启动时把上次服务异常退出留下的"未完结"同步历史标记为 INTERRUPTED。
- * 仅处理 RUNNING / CREATED / STOPPING 这些非终态。
+ * 处理所有非终态(排队、运行、暂停、取消中、重试等,见 RunStateMachine)。
* 在 SchedulerRunner 之前执行(@Order 较小),避免被即将启动的定时任务"运行中"覆盖。
*
* On startup, mark sync-history rows left in transient states by a prior crash as INTERRUPTED.
@@ -29,11 +29,7 @@
public class DatasetHistoryRecoveryRunner
implements CommandLineRunner
{
- private static final List TRANSIENT_STATES = Arrays.asList(
- RunState.RUNNING,
- RunState.CREATED,
- RunState.STOPPING
- );
+ private static final List TRANSIENT_STATES = RunStateMachine.activeStates();
private final DatasetHistoryRepository historyRepository;
private final InstallationState installationState;
@@ -62,6 +58,11 @@ public void run(String... args)
Date updateTime = new Date();
for (DatasetHistoryEntity history : stale) {
RunState previous = history.getState();
+ // 只有暂停或等待重试时的断点是精确的:运行中崩溃时断点之后可能还提交过行
+ // Only a paused or waiting run has an exact checkpoint: a crash mid-run may have committed rows past it.
+ if (previous != RunState.PAUSED && previous != RunState.RETRYING) {
+ history.setCheckpoint(null);
+ }
history.setState(RunState.INTERRUPTED);
history.setMessage(message);
history.setUpdateTime(updateTime);
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/service/PluginReferences.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/service/PluginReferences.java
new file mode 100644
index 0000000000..1d23b68de1
--- /dev/null
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/service/PluginReferences.java
@@ -0,0 +1,130 @@
+package org.devlive.metaforge.server.service;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import org.devlive.metaforge.plugin.PluginMetadata;
+import org.devlive.metaforge.plugin.PluginType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+@Component
+@SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "JdbcTemplate is a shared Spring bean")
+public class PluginReferences
+{
+ private final JdbcTemplate jdbc;
+
+ public PluginReferences(JdbcTemplate jdbc)
+ {
+ this.jdbc = jdbc;
+ }
+
+ /** Missing entries mean the plugin type has no tracked business references. */
+ public Map> list(List plugins)
+ {
+ Map> references = new LinkedHashMap<>();
+ Map>> byType = new LinkedHashMap<>();
+ for (PluginMetadata plugin : plugins) {
+ PluginType type = plugin.getType();
+ if (type != PluginType.CONNECTOR && type != PluginType.EXECUTOR && type != PluginType.SCHEDULER) {
+ continue;
+ }
+ List items = new ArrayList<>();
+ references.put(plugin.getName(), items);
+ Map> names = byType.computeIfAbsent(type, key -> new LinkedHashMap<>());
+ names.put(normalize(plugin.getName()), items);
+ }
+ Map> executors = byType.get(PluginType.EXECUTOR);
+ if (executors != null && executors.containsKey("metaforge")) {
+ executors.putIfAbsent("local", executors.get("metaforge"));
+ executors.putIfAbsent("localexecutor", executors.get("metaforge"));
+ }
+ collect(byType.get(PluginType.CONNECTOR), "source",
+ "SELECT source_type, name FROM metaforge_source ORDER BY name");
+ collect(executors, "dataset", "SELECT executor, name FROM metaforge_dataset ORDER BY name");
+ collect(executors, "workflow", "SELECT executor, name FROM metaforge_workflow ORDER BY name");
+ collect(byType.get(PluginType.SCHEDULER), "dataset", "SELECT scheduler, name FROM metaforge_dataset ORDER BY name");
+ return references;
+ }
+
+ public boolean isInUse(String name, List plugins)
+ {
+ PluginMetadata plugin = plugins.stream().filter(item -> item.getName().equals(name)).findFirst().orElse(null);
+ if (plugin == null) {
+ return false;
+ }
+ String normalized = normalize(name);
+ if (plugin.getType() == PluginType.CONNECTOR) {
+ return exists("SELECT COUNT(*) FROM metaforge_source WHERE LOWER(source_type) = ?", normalized);
+ }
+ if (plugin.getType() == PluginType.EXECUTOR) {
+ if (exists("SELECT COUNT(*) FROM metaforge_dataset WHERE LOWER(executor) = ?", normalized)
+ || exists("SELECT COUNT(*) FROM metaforge_workflow WHERE LOWER(executor) = ?", normalized)) {
+ return true;
+ }
+ if ("metaforge".equals(normalized)) {
+ for (String alias : List.of("local", "localexecutor")) {
+ boolean registered = plugins.stream().anyMatch(item -> normalize(item.getName()).equals(alias));
+ if (!registered && (exists("SELECT COUNT(*) FROM metaforge_dataset WHERE LOWER(executor) = ?", alias)
+ || exists("SELECT COUNT(*) FROM metaforge_workflow WHERE LOWER(executor) = ?", alias))) {
+ return true;
+ }
+ }
+ }
+ }
+ if (plugin.getType() == PluginType.SCHEDULER) {
+ return exists("SELECT COUNT(*) FROM metaforge_dataset WHERE LOWER(scheduler) = ?", normalized);
+ }
+ return false;
+ }
+
+ private boolean exists(String sql, String value)
+ {
+ Long count = jdbc.queryForObject(sql, Long.class, value);
+ return count != null && count > 0;
+ }
+
+ private void collect(Map> names, String kind, String sql)
+ {
+ if (names == null) {
+ return;
+ }
+ jdbc.query(sql, result -> {
+ List items = names.get(normalize(result.getString(1)));
+ if (items != null) {
+ items.add(new Reference(kind, result.getString(2)));
+ }
+ });
+ }
+
+ private static String normalize(String value)
+ {
+ return value == null ? "" : value.toLowerCase(Locale.ROOT);
+ }
+
+ public static final class Reference
+ {
+ private final String kind;
+ private final String name;
+
+ Reference(String kind, String name)
+ {
+ this.kind = kind;
+ this.name = name;
+ }
+
+ public String getKind()
+ {
+ return kind;
+ }
+
+ public String getName()
+ {
+ return name;
+ }
+ }
+}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/service/PluginStore.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/service/PluginStore.java
new file mode 100644
index 0000000000..57b13e01cc
--- /dev/null
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/service/PluginStore.java
@@ -0,0 +1,564 @@
+package org.devlive.metaforge.server.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.Builder;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.BiPredicate;
+import java.util.regex.Pattern;
+
+/**
+ * Downloads store packages through the ForgeStats statistics service.
+ *
+ * The service answers a download with a 302 to the chosen source (Maven Central, the
+ * MetaForge CDN) together with a signed receipt and the official SHA-256 and size. The
+ * package is verified against those values before installation, and a successful
+ * installation is reported so the store can show download counts. A self-hosted mirror
+ * uses the receipt and checksums but downloads from the mirror. When the service is
+ * unreachable or does not list the version, the package is downloaded directly and
+ * verified against the catalogue values, so installing never depends on statistics.
+ */
+@Slf4j
+@Component
+public class PluginStore
+{
+ static final long MAX_PACKAGE_SIZE = 512L * 1024 * 1024;
+ private static final Pattern KEY = Pattern.compile("^[a-z0-9][a-z0-9._-]{0,190}$");
+ private static final Pattern VERSION = Pattern.compile("^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$");
+ private static final Pattern SHA256 = Pattern.compile("^[0-9a-f]{64}$");
+ private static final List SOURCES = List.of("maven", "cdn");
+ private static final Duration COUNTS_TTL = Duration.ofSeconds(60);
+ private static final Duration CATALOGUE_TTL = Duration.ofSeconds(60);
+ private static final int MAX_CATALOGUE_SIZE = 16 * 1024 * 1024;
+
+ private final String statsUrl;
+ private final String app;
+ private final String source;
+ private final String mirror;
+ private final String metadataUrl;
+ private final HttpClient http;
+ // Redirect targets must be HTTPS; tests against a local server relax this.
+ private final boolean requireHttps;
+ private final ObjectMapper json = new ObjectMapper();
+ private final SecureRandom random = new SecureRandom();
+ private final Map counts = new ConcurrentHashMap<>();
+ private volatile CachedCatalogue catalogue;
+
+ // Two constructors: Spring must be told which one to use, or it looks for a no-argument one.
+ @Autowired
+ public PluginStore(@Value("${plugin.store.stats-url:https://forgestats.devlive.org}") String statsUrl,
+ @Value("${plugin.store.app:metaforge}") String app,
+ @Value("${plugin.store.source:maven}") String source,
+ @Value("${plugin.store.mirror:}") String mirror,
+ @Value("${plugin.store.metadata-url:https://cdn.north.devlive.org/applications/metaforge/metadata/metadata.json}") String metadataUrl)
+ {
+ this(statsUrl, app, source, mirror, metadataUrl, HttpClient.newBuilder()
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .connectTimeout(Duration.ofSeconds(5))
+ .build(), true);
+ }
+
+ PluginStore(String statsUrl, String app, String source, String mirror, String metadataUrl, HttpClient http, boolean requireHttps)
+ {
+ this.requireHttps = requireHttps;
+ this.statsUrl = trimSlash(statsUrl);
+ this.app = app == null ? "" : app.trim();
+ this.source = source == null || source.isBlank() ? "maven" : source.trim().toLowerCase(Locale.ROOT);
+ this.mirror = trimSlash(mirror);
+ this.metadataUrl = metadataUrl == null ? "" : metadataUrl.trim();
+ this.http = http;
+ if ("mirror".equals(this.source) && this.mirror.isEmpty()) {
+ log.warn("plugin.store.source=mirror needs plugin.store.mirror; downloads use the default source");
+ }
+ }
+
+ /** What the store asks to install: the catalogue entry of one plugin version. */
+ @Data
+ @Builder
+ public static class Package
+ {
+ private String key;
+ private String version;
+ private String url;
+ private String sha256;
+ private Long size;
+ }
+
+ @Data
+ @Builder
+ static class Receipt
+ {
+ private String attemptId;
+ private String token;
+ private String sha256;
+ private long size;
+ private String location;
+ }
+
+ /** Thrown when a package cannot be fetched or fails verification. */
+ public static class StoreException
+ extends IOException
+ {
+ public StoreException(String message)
+ {
+ super(message);
+ }
+ }
+
+ /**
+ * Fetches and verifies a package, hands the local archive to {@code installer} and reports a
+ * successful installation. The archive is deleted afterwards.
+ */
+ public boolean install(Package target, BiPredicate installer)
+ throws IOException
+ {
+ if (target.getKey() == null || !KEY.matcher(target.getKey()).matches()) {
+ throw new StoreException("Invalid plugin key");
+ }
+ if (target.getVersion() == null || !VERSION.matcher(target.getVersion()).matches()) {
+ throw new StoreException("Invalid plugin version");
+ }
+ String expectedSha = target.getSha256() == null ? null : target.getSha256().toLowerCase(Locale.ROOT);
+ if (expectedSha != null && !SHA256.matcher(expectedSha).matches()) {
+ throw new StoreException("Invalid package checksum");
+ }
+ Path directory = Files.createTempDirectory("metaforge-store-");
+ Path archive = directory.resolve(target.getKey() + "-bin.tar.gz");
+ try {
+ Optional receipt = fetchCounted(target, archive);
+ if (receipt.isEmpty()) {
+ if (target.getUrl() == null || target.getUrl().isBlank()) {
+ throw new StoreException("The statistics service is unavailable and the catalogue has no package URL");
+ }
+ download(URI.create(target.getUrl()), archive, expectedSha, target.getSize());
+ }
+ else if (expectedSha != null && !expectedSha.equals(receipt.get().getSha256())) {
+ // The catalogue and the release record disagree; trust neither silently.
+ throw new StoreException("The package checksum differs from the store catalogue");
+ }
+ if (!installer.test(archive, target.getKey())) {
+ return false;
+ }
+ receipt.ifPresent(this::report);
+ return true;
+ }
+ finally {
+ deleteQuietly(archive);
+ deleteQuietly(directory);
+ }
+ }
+
+ /** Counted download through the statistics service; empty when it cannot be used. */
+ private Optional fetchCounted(Package target, Path archive)
+ throws StoreException
+ {
+ if (statsUrl.isEmpty() || app.isEmpty()) {
+ return Optional.empty();
+ }
+ String attempt = randomHex(16);
+ boolean useMirror = "mirror".equals(source) && !mirror.isEmpty();
+ List order = new ArrayList<>();
+ if (useMirror) {
+ order.add("");
+ }
+ else {
+ order.add(SOURCES.contains(source) ? source : "maven");
+ SOURCES.stream().filter(name -> !order.contains(name)).forEach(order::add);
+ }
+ StoreException verification = null;
+ for (String name : order) {
+ Optional receipt = issue(target, name, attempt);
+ if (receipt.isEmpty()) {
+ // Unknown version, unreachable service: not counted, but still installable.
+ return Optional.empty();
+ }
+ URI location = useMirror ? URI.create(mirror + "/" + cdnPath(target.getKey(), target.getVersion()))
+ : URI.create(receipt.get().getLocation());
+ try {
+ download(location, archive, receipt.get().getSha256(), receipt.get().getSize());
+ return receipt;
+ }
+ catch (StoreException e) {
+ verification = e;
+ log.warn("Plugin {} {} from source [ {} ] failed: {}", target.getKey(), target.getVersion(), name.isEmpty() ? "mirror" : name, e.getMessage());
+ }
+ }
+ throw verification;
+ }
+
+ Optional issue(Package target, String sourceName, String attempt)
+ {
+ String path = String.format("%s/v1/apps/%s/plugins/%s/versions/%s/download%s", statsUrl, app, target.getKey(), target.getVersion(),
+ sourceName.isEmpty() ? "" : "?source=" + sourceName);
+ try {
+ HttpRequest request = HttpRequest.newBuilder(URI.create(path))
+ .timeout(Duration.ofSeconds(8))
+ .header("Idempotency-Key", attempt)
+ .header("User-Agent", "metaforge-server")
+ .GET()
+ .build();
+ HttpResponse response = http.send(request, HttpResponse.BodyHandlers.discarding());
+ if (response.statusCode() != 302) {
+ log.info("Statistics service answered {} for {} {}; downloading without counting", response.statusCode(), target.getKey(), target.getVersion());
+ return Optional.empty();
+ }
+ String id = response.headers().firstValue("X-Forge-Attempt-Id").orElse("");
+ String token = response.headers().firstValue("X-Forge-Receipt").orElse("");
+ String sha = response.headers().firstValue("X-Forge-Sha256").orElse("");
+ long size = Long.parseLong(response.headers().firstValue("X-Forge-Size").orElse("0"));
+ String location = response.headers().firstValue("Location").orElse("");
+ if (!attempt.equals(id) || token.isEmpty() || !SHA256.matcher(sha).matches() || size <= 0 || size > MAX_PACKAGE_SIZE
+ || !(location.startsWith("https://") || (!requireHttps && location.startsWith("http://")))) {
+ log.warn("Statistics service returned an incomplete download for {} {}", target.getKey(), target.getVersion());
+ return Optional.empty();
+ }
+ return Optional.of(Receipt.builder().attemptId(id).token(token).sha256(sha).size(size).location(location).build());
+ }
+ catch (IOException | IllegalArgumentException e) {
+ log.info("Statistics service unavailable for {} {}: {}", target.getKey(), target.getVersion(), e.getMessage());
+ return Optional.empty();
+ }
+ catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return Optional.empty();
+ }
+ }
+
+ /** Downloads to {@code archive}, enforcing the expected size and SHA-256 when known. */
+ void download(URI location, Path archive, String sha256, Long size)
+ throws StoreException
+ {
+ try {
+ HttpRequest request = HttpRequest.newBuilder(location)
+ .timeout(Duration.ofMinutes(10))
+ .header("User-Agent", "metaforge-server")
+ .GET()
+ .build();
+ HttpClient client = HttpClient.newBuilder()
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .connectTimeout(Duration.ofSeconds(10))
+ .build();
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
+ try (InputStream body = response.body()) {
+ if (response.statusCode() != 200) {
+ throw new StoreException("Package download failed with HTTP " + response.statusCode());
+ }
+ long limit = size != null && size > 0 ? size : MAX_PACKAGE_SIZE;
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ long total = 0;
+ byte[] buffer = new byte[64 * 1024];
+ try (OutputStream out = Files.newOutputStream(archive)) {
+ for (int read = body.read(buffer); read != -1; read = body.read(buffer)) {
+ total += read;
+ if (total > limit) {
+ throw new StoreException("The package is larger than its published size");
+ }
+ digest.update(buffer, 0, read);
+ out.write(buffer, 0, read);
+ }
+ }
+ if (size != null && size > 0 && total != size) {
+ throw new StoreException("The package size differs from its published size");
+ }
+ String actual = hex(digest.digest());
+ if (sha256 != null && !sha256.equals(actual)) {
+ throw new StoreException("The package checksum does not match");
+ }
+ }
+ }
+ catch (StoreException e) {
+ deleteQuietly(archive);
+ throw e;
+ }
+ catch (IOException | IllegalArgumentException | NoSuchAlgorithmException e) {
+ deleteQuietly(archive);
+ throw new StoreException("Package download failed: " + e.getMessage());
+ }
+ catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ deleteQuietly(archive);
+ throw new StoreException("Package download interrupted");
+ }
+ }
+
+ /** Reports a verified installation; failures only cost one count. */
+ void report(Receipt receipt)
+ {
+ String body = String.format("{\"sha256\":\"%s\",\"size\":%d}", receipt.getSha256(), receipt.getSize());
+ URI uri = URI.create(String.format("%s/v1/apps/%s/downloads/%s/complete", statsUrl, app, receipt.getAttemptId()));
+ for (int attempt = 0; attempt < 3; attempt++) {
+ try {
+ HttpRequest request = HttpRequest.newBuilder(uri)
+ .timeout(Duration.ofSeconds(5))
+ .header("Content-Type", "application/json")
+ .header("Authorization", "Bearer " + receipt.getToken())
+ .header("User-Agent", "metaforge-server")
+ .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
+ .build();
+ int status = http.send(request, HttpResponse.BodyHandlers.discarding()).statusCode();
+ if (status == 200) {
+ return;
+ }
+ if (status != 429 && status < 500) {
+ log.warn("Statistics service rejected the download report with HTTP {}", status);
+ return;
+ }
+ }
+ catch (IOException e) {
+ log.debug("Download report failed: {}", e.getMessage());
+ }
+ catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ try {
+ Thread.sleep(200L * (attempt + 1));
+ }
+ catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ log.warn("Download report for attempt {} was not delivered", receipt.getAttemptId());
+ }
+
+ /**
+ * The remote store catalogue, served to the browser through the controller so the store
+ * never makes cross-origin requests. Cached briefly; {@code null} when the URL is unset
+ * or the catalogue cannot be fetched, which lets the store fall back to its bundled copy.
+ */
+ public JsonNode catalogue()
+ {
+ if (metadataUrl.isEmpty()) {
+ return null;
+ }
+ CachedCatalogue cached = catalogue;
+ if (cached != null && System.nanoTime() - cached.at < CATALOGUE_TTL.toNanos()) {
+ return cached.value;
+ }
+ synchronized (this) {
+ cached = catalogue;
+ if (cached != null && System.nanoTime() - cached.at < CATALOGUE_TTL.toNanos()) {
+ return cached.value;
+ }
+ JsonNode fetched = fetchCatalogue();
+ if (fetched != null) {
+ catalogue = new CachedCatalogue(fetched, System.nanoTime());
+ }
+ return fetched;
+ }
+ }
+
+ private JsonNode fetchCatalogue()
+ {
+ try {
+ HttpRequest request = HttpRequest.newBuilder(URI.create(metadataUrl))
+ .timeout(Duration.ofSeconds(8))
+ .header("User-Agent", "metaforge-server")
+ .GET()
+ .build();
+ HttpClient client = HttpClient.newBuilder()
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .connectTimeout(Duration.ofSeconds(10))
+ .build();
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() != 200) {
+ log.info("The plugin catalogue answered {} for {}", response.statusCode(), metadataUrl);
+ return null;
+ }
+ String body = response.body();
+ if (body == null || body.isBlank()) {
+ log.warn("The plugin catalogue is empty: {}", metadataUrl);
+ return null;
+ }
+ if (body.length() > MAX_CATALOGUE_SIZE) {
+ log.warn("The plugin catalogue exceeds {} bytes: {}", MAX_CATALOGUE_SIZE, metadataUrl);
+ return null;
+ }
+ JsonNode value = json.readTree(body);
+ if (!value.isObject()) {
+ log.warn("The plugin catalogue is not a JSON object: {}", metadataUrl);
+ return null;
+ }
+ return value;
+ }
+ catch (IOException | IllegalArgumentException e) {
+ log.info("The plugin catalogue is unavailable: {}", e.getMessage());
+ return null;
+ }
+ catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return null;
+ }
+ }
+
+ /**
+ * Total confirmed downloads per plugin key, as decimal strings. Keys the service does not
+ * know, or every key when it is unreachable, are absent: the store shows them as unknown.
+ */
+ public Map downloads(Collection keys)
+ {
+ Map out = new LinkedHashMap<>();
+ if (statsUrl.isEmpty() || app.isEmpty()) {
+ return out;
+ }
+ long now = System.nanoTime();
+ List missing = new ArrayList<>();
+ for (String key : keys) {
+ if (key == null || !KEY.matcher(key).matches()) {
+ continue;
+ }
+ CachedCount cached = counts.get(key);
+ if (cached != null && now - cached.at < COUNTS_TTL.toNanos()) {
+ if (cached.value != null) {
+ out.put(key, cached.value);
+ }
+ }
+ else if (!missing.contains(key)) {
+ missing.add(key);
+ }
+ }
+ for (int from = 0; from < missing.size(); from += 100) {
+ List batch = missing.subList(from, Math.min(missing.size(), from + 100));
+ Map fetched = fetchCounts(batch);
+ if (fetched == null) {
+ break;
+ }
+ for (String key : batch) {
+ String value = fetched.get(key);
+ counts.put(key, new CachedCount(value, now));
+ if (value != null) {
+ out.put(key, value);
+ }
+ }
+ }
+ return out;
+ }
+
+ private Map fetchCounts(List keys)
+ {
+ try {
+ HttpRequest request = HttpRequest.newBuilder(URI.create(String.format("%s/v1/apps/%s/stats?ids=%s", statsUrl, app, String.join(",", keys))))
+ .timeout(Duration.ofSeconds(5))
+ .header("User-Agent", "metaforge-server")
+ .GET()
+ .build();
+ HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() != 200) {
+ return null;
+ }
+ Map out = new LinkedHashMap<>();
+ for (JsonNode plugin : json.readTree(response.body()).path("plugins")) {
+ String id = plugin.path("pluginId").asText("");
+ String total = plugin.path("totalDownloads").asText("");
+ if (!id.isEmpty() && total.matches("^\\d+$")) {
+ out.put(id, total);
+ }
+ }
+ return out;
+ }
+ catch (IOException | IllegalArgumentException e) {
+ log.debug("Download counts unavailable: {}", e.getMessage());
+ return null;
+ }
+ catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return null;
+ }
+ }
+
+ /** The CDN layout: {version}/{type}/{key}-bin.tar.gz, shared by self-hosted mirrors. */
+ static String cdnPath(String key, String version)
+ {
+ String directory = "plugin";
+ for (String type : List.of("executor", "convert", "fs", "parser", "scheduler")) {
+ if (key.startsWith("metaforge-" + type + "-")) {
+ directory = type;
+ break;
+ }
+ }
+ return version + "/" + directory + "/" + key + "-bin.tar.gz";
+ }
+
+ private String randomHex(int bytes)
+ {
+ byte[] value = new byte[bytes];
+ random.nextBytes(value);
+ return hex(value);
+ }
+
+ private static String hex(byte[] bytes)
+ {
+ StringBuilder out = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes) {
+ out.append(Character.forDigit((b >> 4) & 0xf, 16)).append(Character.forDigit(b & 0xf, 16));
+ }
+ return out.toString();
+ }
+
+ private static String trimSlash(String value)
+ {
+ return value == null ? "" : value.trim().replaceAll("/+$", "");
+ }
+
+ private static void deleteQuietly(Path path)
+ {
+ try {
+ Files.deleteIfExists(path);
+ }
+ catch (IOException e) {
+ log.debug("Cannot delete {}", path, e);
+ }
+ }
+
+ private static final class CachedCount
+ {
+ private final String value;
+ private final long at;
+
+ private CachedCount(String value, long at)
+ {
+ this.value = value;
+ this.at = at;
+ }
+ }
+
+ private static final class CachedCatalogue
+ {
+ private final JsonNode value;
+ private final long at;
+
+ private CachedCatalogue(JsonNode value, long at)
+ {
+ this.value = value;
+ this.at = at;
+ }
+ }
+}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/web/WebUiDocument.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/web/WebUiDocument.java
new file mode 100644
index 0000000000..63b8c0d3b9
--- /dev/null
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/web/WebUiDocument.java
@@ -0,0 +1,42 @@
+package org.devlive.metaforge.server.web;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+
+/** The web UI document (index.html) packaged with the server, read once. */
+@Slf4j
+@Component
+public class WebUiDocument
+{
+ private static final String[] LOCATIONS = {"resources/index.html", "static/index.html", "public/index.html"};
+
+ private volatile String html;
+
+ public Optional html()
+ {
+ String cached = html;
+ if (cached != null) {
+ return Optional.of(cached);
+ }
+ for (String location : LOCATIONS) {
+ ClassPathResource resource = new ClassPathResource(location);
+ if (!resource.exists()) {
+ continue;
+ }
+ try (InputStream stream = resource.getInputStream()) {
+ html = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+ return Optional.of(html);
+ }
+ catch (IOException e) {
+ log.warn("Cannot read the web UI document {}", location, e);
+ }
+ }
+ return Optional.empty();
+ }
+}
diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/web/WebUiFilter.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/web/WebUiFilter.java
new file mode 100644
index 0000000000..345f2e4601
--- /dev/null
+++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/web/WebUiFilter.java
@@ -0,0 +1,56 @@
+package org.devlive.metaforge.server.web;
+
+import org.devlive.metaforge.security.WebUiRequestMatcher;
+import org.springframework.http.CacheControl;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+
+/**
+ * Answers browser page requests (/query, /admin/user ...) with the web UI document, so addresses of
+ * the history-routed UI open directly and survive a reload. Everything else continues as usual.
+ */
+@Component
+public class WebUiFilter
+ extends OncePerRequestFilter
+{
+ private final WebUiRequestMatcher pages = new WebUiRequestMatcher();
+ private final WebUiDocument document;
+
+ public WebUiFilter(WebUiDocument document)
+ {
+ this.document = document;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException
+ {
+ String accept = request.getHeader(HttpHeaders.ACCEPT);
+ Optional html = pages.matches(request) && accept != null && accept.contains(MediaType.TEXT_HTML_VALUE) ? document.html() : Optional.empty();
+ if (html.isEmpty()) {
+ chain.doFilter(request, response);
+ return;
+ }
+ byte[] body = html.get().getBytes(StandardCharsets.UTF_8);
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.setContentType(MediaType.TEXT_HTML_VALUE);
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ // The document names hashed assets; it must be revalidated so a deploy shows at once.
+ response.setHeader(HttpHeaders.CACHE_CONTROL, CacheControl.noCache().getHeaderValue());
+ response.setContentLength(body.length);
+ if (!"HEAD".equals(request.getMethod())) {
+ response.getOutputStream().write(body);
+ }
+ }
+}
diff --git a/core/metaforge-server/src/main/resources/application.properties b/core/metaforge-server/src/main/resources/application.properties
index 06f7f9fbb4..11b34d85a9 100644
--- a/core/metaforge-server/src/main/resources/application.properties
+++ b/core/metaforge-server/src/main/resources/application.properties
@@ -45,6 +45,14 @@ metaforge.audit.sql.print=false
plugin.manager.extend.packages=com.fasterxml.jackson
# 0 = automatic (up to 4 workers), 1 = sequential
plugin.manager.load-threads=0
+# Plugin store: statistics service, download source (maven | cdn | mirror) and an optional mirror base
+plugin.store.stats-url=https://forgestats.devlive.org
+plugin.store.app=metaforge
+plugin.store.source=maven
+plugin.store.mirror=
+# The store catalogue is fetched server-side and served to the browser through
+# /api/v1/plugin/catalogue; empty disables the remote catalogue.
+plugin.store.metadata-url=https://cdn.north.devlive.org/applications/metaforge/metadata/metadata.json
########################### Upload ###########################
# Import files (SQL dumps, CSV, JSON ...) are analysed on upload.
diff --git a/core/metaforge-server/src/main/resources/db/changelog/core/core-0007-system-settings.xml b/core/metaforge-server/src/main/resources/db/changelog/core/core-0007-system-settings.xml
new file mode 100644
index 0000000000..4501b2f1a8
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/changelog/core/core-0007-system-settings.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/changelog/core/core-0008-user-oauth.xml b/core/metaforge-server/src/main/resources/db/changelog/core/core-0008-user-oauth.xml
new file mode 100644
index 0000000000..938d64ac58
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/changelog/core/core-0008-user-oauth.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/changelog/core/core-0009-runtime-settings-in-system-settings.xml b/core/metaforge-server/src/main/resources/db/changelog/core/core-0009-runtime-settings-in-system-settings.xml
new file mode 100644
index 0000000000..13b9b74c4c
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/changelog/core/core-0009-runtime-settings-in-system-settings.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/changelog/core/core-0010-metaforge-executor.xml b/core/metaforge-server/src/main/resources/db/changelog/core/core-0010-metaforge-executor.xml
new file mode 100644
index 0000000000..98549da359
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/changelog/core/core-0010-metaforge-executor.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+ executor IN ('Local', 'LocalExecutor')
+
+
+
+ executor IN ('Local', 'LocalExecutor')
+
+
+
+
+
+
+
+
+
+ SELECT COUNT(*) FROM metaforge_configure WHERE category = 'EXECUTOR' AND name = 'Local'
+
+
+
+ category = 'EXECUTOR' AND name = 'MetaForge'
+
+
+
+
+
+
+ category = 'EXECUTOR' AND name = 'Local'
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/changelog/core/core-0011-dataset-history-metrics.xml b/core/metaforge-server/src/main/resources/db/changelog/core/core-0011-dataset-history-metrics.xml
new file mode 100644
index 0000000000..62498b4dbb
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/changelog/core/core-0011-dataset-history-metrics.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/changelog/core/core-0012-report-share.xml b/core/metaforge-server/src/main/resources/db/changelog/core/core-0012-report-share.xml
new file mode 100644
index 0000000000..b37e3543b7
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/changelog/core/core-0012-report-share.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/changelog/core/core-0013-report-share-options.xml b/core/metaforge-server/src/main/resources/db/changelog/core/core-0013-report-share-options.xml
new file mode 100644
index 0000000000..114dac2fd6
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/changelog/core/core-0013-report-share-options.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/changelog/core/core-0014-report-share-data.xml b/core/metaforge-server/src/main/resources/db/changelog/core/core-0014-report-share-data.xml
new file mode 100644
index 0000000000..dfc981e2ed
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/changelog/core/core-0014-report-share-data.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/changelog/core/core-0015-notification-content.xml b/core/metaforge-server/src/main/resources/db/changelog/core/core-0015-notification-content.xml
new file mode 100644
index 0000000000..55c9b66946
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/changelog/core/core-0015-notification-content.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/changelog/core/core-0016-user-home-page.xml b/core/metaforge-server/src/main/resources/db/changelog/core/core-0016-user-home-page.xml
new file mode 100644
index 0000000000..31c05e40ca
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/changelog/core/core-0016-user-home-page.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/changelog/core/core-0017-screen-paths.xml b/core/metaforge-server/src/main/resources/db/changelog/core/core-0017-screen-paths.xml
new file mode 100644
index 0000000000..3baa630fcc
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/changelog/core/core-0017-screen-paths.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+ url = '/dashboard'
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/changelog/core/core-0018-dashboard-share.xml b/core/metaforge-server/src/main/resources/db/changelog/core/core-0018-dashboard-share.xml
new file mode 100644
index 0000000000..80c0299a07
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/changelog/core/core-0018-dashboard-share.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/changelog/core/core-0019-dataset-history-checkpoint.xml b/core/metaforge-server/src/main/resources/db/changelog/core/core-0019-dataset-history-checkpoint.xml
new file mode 100644
index 0000000000..ebdab09ce9
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/changelog/core/core-0019-dataset-history-checkpoint.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/changelog/db.changelog-master.xml b/core/metaforge-server/src/main/resources/db/changelog/db.changelog-master.xml
index 118fe3fa77..26766b7efd 100644
--- a/core/metaforge-server/src/main/resources/db/changelog/db.changelog-master.xml
+++ b/core/metaforge-server/src/main/resources/db/changelog/db.changelog-master.xml
@@ -32,4 +32,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/metaforge-server/src/main/resources/db/seed/baseline.json b/core/metaforge-server/src/main/resources/db/seed/baseline.json
index 8118733a5b..9aaba01a63 100644
--- a/core/metaforge-server/src/main/resources/db/seed/baseline.json
+++ b/core/metaforge-server/src/main/resources/db/seed/baseline.json
@@ -212,7 +212,7 @@
"code": "DASHBOARD",
"name": "全局 - 仪表盘",
"description": "全局路由:所有用户都可以访问",
- "url": "/dashboard",
+ "url": "/screen",
"group": null,
"sorted": 1,
"type": "VIEW",
@@ -279,17 +279,17 @@
"isNew": true
},
{
- "code": "RUNTIME_CONFIGURE",
- "name": "管理员 - 系统 - 运行时配置",
+ "code": "SYSTEM_SETTINGS",
+ "name": "管理员 - 系统 - 系统设置",
"description": "管理员:管理员权限用户可以访问\\n位置:顶部管理一级子菜单",
- "url": "/admin/configure",
+ "url": "/admin/settings",
"group": null,
- "sorted": 6,
+ "sorted": 7,
"type": "VIEW",
"parent": "SYSTEM",
"active": true,
- "i18nKey": "configure.runtime.title",
- "icon": "Settings2",
+ "i18nKey": "navigation.systemSettings",
+ "icon": "SlidersHorizontal",
"isNew": false
},
{
@@ -326,7 +326,7 @@
"DATASET",
"STORE",
"b6669c359a7d41e581d906446366daf3",
- "RUNTIME_CONFIGURE"
+ "SYSTEM_SETTINGS"
],
"USER": [
"HOME",
diff --git a/core/metaforge-server/src/main/resources/db/seed/system-settings.json b/core/metaforge-server/src/main/resources/db/seed/system-settings.json
new file mode 100644
index 0000000000..bab413d336
--- /dev/null
+++ b/core/metaforge-server/src/main/resources/db/seed/system-settings.json
@@ -0,0 +1,23 @@
+{
+ "menus": [
+ {
+ "code": "SYSTEM_SETTINGS",
+ "name": "管理员 - 系统 - 系统设置",
+ "description": "管理员:管理员权限用户可以访问\\n位置:顶部管理一级子菜单",
+ "url": "/admin/settings",
+ "group": null,
+ "sorted": 7,
+ "type": "VIEW",
+ "parent": "SYSTEM",
+ "active": true,
+ "i18nKey": "navigation.systemSettings",
+ "icon": "SlidersHorizontal",
+ "isNew": false
+ }
+ ],
+ "roleMenus": {
+ "ADMIN": [
+ "SYSTEM_SETTINGS"
+ ]
+ }
+}
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/auth/AuthSettingsTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/auth/AuthSettingsTest.java
new file mode 100644
index 0000000000..b6641b50cb
--- /dev/null
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/auth/AuthSettingsTest.java
@@ -0,0 +1,275 @@
+package org.devlive.metaforge.server.auth;
+
+import org.devlive.metaforge.common.response.CommonResponse;
+import org.devlive.metaforge.plugin.PluginManager;
+import org.devlive.metaforge.service.settings.FsSettings;
+import org.devlive.metaforge.plugin.PluginType;
+import org.devlive.metaforge.plugin.PluginMetadata;
+import org.devlive.metaforge.service.body.AuthBody;
+import org.devlive.metaforge.service.body.EmailCodeBody;
+import org.devlive.metaforge.service.body.PasswordResetBody;
+import org.devlive.metaforge.service.entity.UserEntity;
+import org.devlive.metaforge.service.initializer.InitializerConfigure;
+import org.devlive.metaforge.service.repository.UserRepository;
+import org.devlive.metaforge.service.service.RuntimeConfigureService;
+import org.devlive.metaforge.service.service.SystemSettingService;
+import org.devlive.metaforge.service.service.UserService;
+import org.devlive.metaforge.service.service.impl.AuthServiceImpl;
+import org.devlive.metaforge.service.service.impl.SystemSettingServiceImpl;
+import org.devlive.metaforge.service.settings.AuthSettings;
+import org.devlive.metaforge.service.settings.EmailCodes;
+import org.devlive.metaforge.service.settings.MailService;
+import org.devlive.metaforge.service.settings.MailSettings;
+import org.devlive.metaforge.service.settings.OAuthSettings;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import javax.mail.MessagingException;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class AuthSettingsTest
+{
+ private static final MailSettings MAIL = MailSettings.builder().host("smtp.example.com").port(465).ssl(true).from("noreply@example.com").password("secret").build();
+
+ private SystemSettingService settings;
+ private MailService mail;
+ private UserService users;
+ private UserRepository repository;
+ private EmailCodes codes;
+ private AuthServiceImpl service;
+
+ @BeforeEach
+ public void setUp()
+ {
+ settings = mock(SystemSettingService.class);
+ mail = mock(MailService.class);
+ users = mock(UserService.class);
+ repository = mock(UserRepository.class);
+ codes = new EmailCodes();
+ PasswordEncoder encoder = mock(PasswordEncoder.class);
+ when(encoder.encode(anyString())).thenAnswer(invocation -> "hash:" + invocation.getArgument(0));
+ when(repository.findByEmail(anyString())).thenReturn(Optional.empty());
+ when(settings.oauth(any())).thenReturn(new OAuthSettings());
+ service = new AuthServiceImpl(settings, codes, mail, users, repository, encoder, mock(InitializerConfigure.class));
+ }
+
+ private void given(AuthSettings auth, MailSettings server)
+ {
+ when(settings.auth()).thenReturn(auth);
+ when(settings.mail()).thenReturn(server);
+ }
+
+ private static AuthSettings.AuthSettingsBuilder auth()
+ {
+ return AuthSettings.builder().registration(true).rememberDays(30);
+ }
+
+ private static UserEntity user(String username, String email)
+ {
+ UserEntity user = new UserEntity();
+ user.setUsername(username);
+ user.setEmail(email);
+ return user;
+ }
+
+ @Test
+ public void emailFeaturesAreOfferedOnlyWhenTurnedOnAndMailCanBeSent()
+ {
+ given(auth().emailVerification(true).passwordReset(true).build(), new MailSettings());
+ Map options = service.options();
+ assertEquals(false, options.get("emailVerification"));
+ assertEquals(false, options.get("passwordReset"));
+ assertEquals(false, options.get("rememberMe"));
+
+ given(auth().emailVerification(true).passwordReset(true).rememberMe(true).build(), MAIL);
+ options = service.options();
+ assertEquals(true, options.get("emailVerification"));
+ assertEquals(true, options.get("passwordReset"));
+ assertEquals(true, options.get("rememberMe"));
+ assertFalse(service.sendCode(body("a@example.com", "unknown")).getStatus());
+ }
+
+ private static EmailCodeBody body(String email, String purpose)
+ {
+ EmailCodeBody body = new EmailCodeBody();
+ body.setEmail(email);
+ body.setPurpose(purpose);
+ return body;
+ }
+
+ @Test
+ public void signUpWithVerificationNeedsTheMailedCode()
+ throws MessagingException
+ {
+ given(auth().emailVerification(true).build(), MAIL);
+ when(users.saveOrUpdate(any(UserEntity.class))).thenReturn(CommonResponse.success(new UserEntity()));
+ assertTrue(service.sendCode(body("New@Example.com", "signup")).getStatus());
+ ArgumentCaptor text = ArgumentCaptor.forClass(String.class);
+ verify(mail).send(eq(MAIL), eq("new@example.com"), anyString(), text.capture());
+ String code = text.getValue().replaceAll("(?s).*?(\\d{6}).*", "$1");
+
+ // Asking again within a minute is refused.
+ assertFalse(service.sendCode(body("new@example.com", "signup")).getStatus());
+
+ AuthBody signup = new AuthBody();
+ signup.setUsername("new_user");
+ signup.setPassword("secret1");
+ signup.setEmail("new@example.com");
+ signup.setCode("000000".equals(code) ? "111111" : "000000");
+ assertFalse(service.signup(signup).getStatus());
+ signup.setCode(code);
+ assertTrue(service.signup(signup).getStatus());
+ ArgumentCaptor saved = ArgumentCaptor.forClass(UserEntity.class);
+ verify(users).saveOrUpdate(saved.capture());
+ assertEquals("new@example.com", saved.getValue().getEmail());
+ // A code is used once.
+ assertFalse(service.signup(signup).getStatus());
+ }
+
+ @Test
+ public void signUpChecksTheSwitchAndTheUsernameRule()
+ {
+ given(auth().registration(false).build(), MAIL);
+ AuthBody signup = new AuthBody();
+ signup.setUsername("valid_name");
+ signup.setPassword("secret1");
+ assertFalse(service.signup(signup).getStatus());
+
+ given(auth().build(), MAIL);
+ signup.setUsername("no spaces!");
+ assertFalse(service.signup(signup).getStatus());
+ verify(users, never()).saveOrUpdate(any(UserEntity.class));
+ }
+
+ @Test
+ public void codesAreNotMailedToUnknownAddressesOrKeptWhenSendingFails()
+ throws MessagingException
+ {
+ given(auth().passwordReset(true).build(), MAIL);
+ assertTrue(service.sendCode(body("nobody@example.com", "reset")).getStatus());
+ verify(mail, never()).send(any(), anyString(), anyString(), anyString());
+
+ when(repository.findByEmail("known@example.com")).thenReturn(Optional.of(user("known", "known@example.com")));
+ doThrow(new MessagingException("refused")).when(mail).send(any(), anyString(), anyString(), anyString());
+ assertFalse(service.sendCode(body("known@example.com", "reset")).getStatus());
+ assertEquals(0, codes.waiting("known@example.com", EmailCodes.Purpose.RESET));
+ }
+
+ @Test
+ public void aMailedCodeResetsThePassword()
+ {
+ given(auth().passwordReset(true).build(), MAIL);
+ UserEntity known = user("known", "known@example.com");
+ when(repository.findByEmail("known@example.com")).thenReturn(Optional.of(known));
+ String code = codes.issue("known@example.com", EmailCodes.Purpose.RESET);
+
+ PasswordResetBody reset = new PasswordResetBody();
+ reset.setEmail("known@example.com");
+ reset.setCode(code);
+ reset.setPassword("short");
+ assertFalse(service.reset(reset).getStatus());
+ reset.setPassword("new-secret");
+ assertTrue(service.reset(reset).getStatus());
+ assertEquals("hash:new-secret", known.getPassword());
+ verify(repository).save(known);
+ }
+
+ @Test
+ public void signInByEmailAndRememberedSignInsLastTheSetDays()
+ {
+ given(auth().emailSignin(true).rememberMe(true).rememberDays(7).build(), new MailSettings());
+ when(repository.findByEmail("known@example.com")).thenReturn(Optional.of(user("known", "known@example.com")));
+ when(users.authenticate(any(UserEntity.class), anyLong())).thenReturn(CommonResponse.success("token"));
+ AuthBody signin = new AuthBody();
+ signin.setUsername("Known@Example.com");
+ signin.setPassword("secret1");
+ signin.setRemember(true);
+ service.signin(signin);
+ ArgumentCaptor user = ArgumentCaptor.forClass(UserEntity.class);
+ verify(users).authenticate(user.capture(), eq(TimeUnit.DAYS.toMillis(7)));
+ assertEquals("known", user.getValue().getUsername());
+
+ // Without the setting, the box is ignored and the default lifetime applies.
+ given(auth().build(), new MailSettings());
+ signin.setUsername("other");
+ service.signin(signin);
+ verify(users).authenticate(any(UserEntity.class), eq(0L));
+ }
+
+ @Test
+ public void savedMailPasswordsAreKeptAndNeverSentBack()
+ {
+ RuntimeConfigureService configures = mock(RuntimeConfigureService.class);
+ InitializerConfigure initializer = mock(InitializerConfigure.class);
+ when(initializer.getRegistrationEnable()).thenReturn(true);
+ when(initializer.getCaptchaEnable()).thenReturn(false);
+ when(configures.getEffective(eq("SYSTEM"), eq("MAIL"), any())).thenReturn(MAIL.toMap());
+ when(configures.getEffective(eq("SYSTEM"), eq("AUTH"), any())).thenReturn(Map.of("registration", "true"));
+ when(configures.save(anyString(), anyString(), anyMap(), any())).thenReturn(CommonResponse.success(null));
+ SystemSettingServiceImpl system = new SystemSettingServiceImpl(configures, initializer, mail, mock(PluginManager.class));
+
+ MailSettings shown = (MailSettings) system.settings().get("mail");
+ assertEquals("", shown.getPassword());
+ assertTrue(shown.isPasswordSet());
+
+ assertTrue(system.save(auth().build(), MAIL.toBuilder().password("").host("smtp.other.com").build(), null).getStatus());
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> values = ArgumentCaptor.forClass(Map.class);
+ verify(configures).save(eq("SYSTEM"), eq("MAIL"), values.capture(), any());
+ assertEquals("secret", values.getValue().get("password"));
+ assertEquals("smtp.other.com", values.getValue().get("host"));
+ }
+
+ @Test
+ public void fileStorageKeepsItsSecretAndOnlyTakesInstalledCompletePlugins()
+ {
+ RuntimeConfigureService configures = mock(RuntimeConfigureService.class);
+ InitializerConfigure initializer = mock(InitializerConfigure.class);
+ PluginManager plugins = mock(PluginManager.class);
+ when(plugins.getPluginInfos()).thenReturn(List.of(PluginMetadata.builder().name("LocalFs").type(PluginType.FILESYSTEM).build(),
+ PluginMetadata.builder().name("AmazonS3Fs").type(PluginType.FILESYSTEM).build(),
+ PluginMetadata.builder().name("MySQL").type(PluginType.CONNECTOR).build()));
+ when(configures.getEffective(anyString(), anyString(), any())).thenReturn(Map.of());
+ when(configures.getEffective(eq("SYSTEM"), eq("FS"), any())).thenReturn(Map.of("type", "AmazonS3Fs", "access", "key", "secret", "stored", "endpoint", "s3.example.com", "bucket", "files"));
+ when(configures.save(anyString(), anyString(), anyMap(), any())).thenReturn(CommonResponse.success(null));
+ SystemSettingServiceImpl system = new SystemSettingServiceImpl(configures, initializer, mail, plugins);
+
+ FsSettings shown = (FsSettings) system.settings().get("fs");
+ assertEquals("", shown.getSecret());
+ assertTrue(shown.isSecretSet());
+ assertEquals(List.of("AmazonS3Fs", "LocalFs"), system.settings().get("fsTypes"));
+
+ FsSettings edited = system.fs().toBuilder().secret("").bucket("archive").build();
+ assertTrue(system.save(auth().build(), new MailSettings(), null, edited).getStatus());
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> values = ArgumentCaptor.forClass(Map.class);
+ verify(configures).save(eq("SYSTEM"), eq("FS"), values.capture(), any());
+ assertEquals("stored", values.getValue().get("secret"));
+ assertEquals("archive", values.getValue().get("bucket"));
+
+ assertFalse(system.save(auth().build(), new MailSettings(), null, FsSettings.builder().type("QiniuFs").build()).getStatus());
+ assertFalse(system.save(auth().build(), new MailSettings(), null, edited.toBuilder().bucket("").build()).getStatus());
+ assertTrue(system.save(auth().build(), new MailSettings(), null, FsSettings.builder().type("LocalFs").build()).getStatus());
+ }
+
+}
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/auth/OAuthSigninTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/auth/OAuthSigninTest.java
new file mode 100644
index 0000000000..a2ee1ac3b4
--- /dev/null
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/auth/OAuthSigninTest.java
@@ -0,0 +1,336 @@
+package org.devlive.metaforge.server.auth;
+
+import org.devlive.metaforge.common.response.CommonResponse;
+import org.devlive.metaforge.plugin.PluginManager;
+import org.devlive.metaforge.service.entity.UserEntity;
+import org.devlive.metaforge.service.entity.UserOAuthEntity;
+import org.devlive.metaforge.service.initializer.InitializerConfigure;
+import org.devlive.metaforge.service.repository.UserOAuthRepository;
+import org.devlive.metaforge.service.repository.UserRepository;
+import org.devlive.metaforge.service.service.OAuthService;
+import org.devlive.metaforge.service.service.RuntimeConfigureService;
+import org.devlive.metaforge.service.service.SystemSettingService;
+import org.devlive.metaforge.service.service.UserService;
+import org.devlive.metaforge.service.service.impl.AuthServiceImpl;
+import org.devlive.metaforge.service.service.impl.OAuthServiceImpl;
+import org.devlive.metaforge.service.service.impl.SystemSettingServiceImpl;
+import org.devlive.metaforge.service.settings.AuthSettings;
+import org.devlive.metaforge.service.settings.EmailCodes;
+import org.devlive.metaforge.service.settings.MailService;
+import org.devlive.metaforge.service.settings.MailSettings;
+import org.devlive.metaforge.service.settings.OAuthClient;
+import org.devlive.metaforge.service.settings.OAuthProvider;
+import org.devlive.metaforge.service.settings.OAuthSettings;
+import org.devlive.metaforge.service.settings.OAuthStates;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestTemplate;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+public class OAuthSigninTest
+{
+ private static final String SITE = "https://metaforge.example.com";
+ private static final OAuthSettings GITHUB = OAuthSettings.from(OAuthProvider.GITHUB,
+ Map.of("enabled", "true", "clientId", "gh-client", "clientSecret", "gh-secret"));
+ private static final OAuthSettings OIDC = OAuthSettings.from(OAuthProvider.OIDC, Map.of("enabled", "true", "clientId", "sso-client",
+ "clientSecret", "sso-secret", "name", "Okta", "authorizationUrl", "https://sso.example.com/authorize", "tokenUrl", "https://sso.example.com/token",
+ "userinfoUrl", "https://sso.example.com/userinfo"));
+
+ private SystemSettingService settings;
+ private UserService users;
+ private UserRepository repository;
+ private UserOAuthRepository links;
+ private MockRestServiceServer server;
+ private OAuthServiceImpl service;
+ private final Map providers = new HashMap<>();
+
+ @BeforeEach
+ public void setUp()
+ {
+ settings = mock(SystemSettingService.class);
+ users = mock(UserService.class);
+ repository = mock(UserRepository.class);
+ links = mock(UserOAuthRepository.class);
+ RestTemplate rest = new RestTemplate();
+ server = MockRestServiceServer.bindTo(rest).build();
+ when(settings.auth()).thenReturn(AuthSettings.builder().registration(true).siteUrl(SITE).build());
+ when(settings.mail()).thenReturn(new MailSettings());
+ when(settings.oauth(any())).thenAnswer(invocation -> providers.getOrDefault(invocation.getArgument(0), new OAuthSettings()));
+ when(repository.findByEmail(anyString())).thenReturn(Optional.empty());
+ when(repository.findByUsername(anyString())).thenReturn(Optional.empty());
+ when(links.findByProviderAndSubject(anyString(), anyString())).thenReturn(Optional.empty());
+ when(users.issue(anyString(), anyLong())).thenAnswer(invocation -> CommonResponse.success("jwt:" + invocation.getArgument(0)));
+ service = new OAuthServiceImpl(settings, new OAuthStates(), new OAuthClient(rest), users, repository, links);
+ }
+
+ private static UserEntity user(long id, String username, String email)
+ {
+ UserEntity user = new UserEntity();
+ user.setId(id);
+ user.setUsername(username);
+ user.setEmail(email);
+ return user;
+ }
+
+ /** The query of a location, including one after the hash of the callback page. */
+ private static Map query(String location)
+ {
+ Map values = new HashMap<>();
+ String query = location.substring(location.indexOf('?') + 1);
+ for (String pair : query.split("&")) {
+ String[] parts = pair.split("=", 2);
+ values.put(URLDecoder.decode(parts[0], StandardCharsets.UTF_8), parts.length > 1 ? URLDecoder.decode(parts[1], StandardCharsets.UTF_8) : "");
+ }
+ return values;
+ }
+
+ private void githubAnswers(String emails)
+ {
+ server.expect(requestTo("https://github.com/login/oauth/access_token"))
+ .andExpect(method(HttpMethod.POST))
+ .andExpect(content().formDataContains(Map.of("code", "the-code", "client_id", "gh-client", "client_secret", "gh-secret",
+ "redirect_uri", SITE + "/api/auth/oauth/github/callback")))
+ .andRespond(withSuccess("{\"access_token\":\"gh-token\",\"token_type\":\"bearer\"}", MediaType.APPLICATION_JSON));
+ server.expect(requestTo("https://api.github.com/user"))
+ .andExpect(header("Authorization", "Bearer gh-token"))
+ .andRespond(withSuccess("{\"id\":42,\"login\":\"octo-cat\",\"email\":null}", MediaType.APPLICATION_JSON));
+ server.expect(requestTo(OAuthProvider.GITHUB_EMAILS))
+ .andRespond(withSuccess(emails, MediaType.APPLICATION_JSON));
+ }
+
+ /** Starts a sign-in and comes back with the code, as the provider would. */
+ private Map signIn(String provider, String redirect)
+ {
+ OAuthService.Redirect started = service.authorize(provider, redirect, "http://ignored");
+ String state = query(started.getLocation()).get("state");
+ return query(service.callback(provider, "the-code", state, started.getState(), null, "http://ignored"));
+ }
+
+ @Test
+ public void onlyProvidersThatAreOnAndConfiguredAreOffered()
+ {
+ AuthServiceImpl auth = new AuthServiceImpl(settings, new EmailCodes(), mock(MailService.class), users, repository, mock(PasswordEncoder.class),
+ mock(InitializerConfigure.class));
+ assertEquals(List.of(), auth.options().get("providers"));
+
+ providers.put(OAuthProvider.GITHUB, GITHUB);
+ providers.put(OAuthProvider.GOOGLE, GITHUB.toBuilder().enabled(false).build());
+ // Turned on, but without its endpoints.
+ providers.put(OAuthProvider.OIDC, OIDC.toBuilder().tokenUrl("").build());
+ assertEquals(List.of(Map.of("id", "github", "name", "GitHub")), auth.options().get("providers"));
+
+ providers.put(OAuthProvider.OIDC, OIDC);
+ assertEquals(List.of(Map.of("id", "github", "name", "GitHub"), Map.of("id", "oidc", "name", "Okta")), auth.options().get("providers"));
+ }
+
+ @Test
+ public void aProviderThatIsOffGoesBackToTheSignInPage()
+ {
+ OAuthService.Redirect started = service.authorize("github", "/query", null);
+ assertNull(started.getState());
+ assertEquals(SITE + "/auth/oauth?error=disabled&redirect=%2Fquery", started.getLocation());
+ assertNull(service.authorize("unknown", null, null).getState());
+ }
+
+ @Test
+ public void theAuthorizationRequestUsesTheSiteAddressStateAndPkce()
+ {
+ providers.put(OAuthProvider.GITHUB, GITHUB);
+ OAuthService.Redirect started = service.authorize("github", "//evil.example.com", "http://internal:9096");
+ assertTrue(started.getLocation().startsWith("https://github.com/login/oauth/authorize?"));
+ Map query = query(started.getLocation());
+ assertEquals("gh-client", query.get("client_id"));
+ assertEquals(SITE + "/api/auth/oauth/github/callback", query.get("redirect_uri"));
+ assertEquals("read:user user:email", query.get("scope"));
+ assertEquals(started.getState(), query.get("state"));
+ assertEquals("S256", query.get("code_challenge_method"));
+ assertEquals(43, query.get("code_challenge").length());
+
+ // Without a site address the request's own address is used; an outside redirect is dropped.
+ when(settings.auth()).thenReturn(AuthSettings.builder().build());
+ started = service.authorize("github", "//evil.example.com", "http://internal:9096/");
+ assertEquals("http://internal:9096/api/auth/oauth/github/callback", query(started.getLocation()).get("redirect_uri"));
+ // A state started for one provider is refused by another.
+ String state = started.getState();
+ assertEquals("http://internal:9096/auth/oauth?error=state", service.callback("google", "the-code", state, state, null, "http://internal:9096"));
+ }
+
+ @Test
+ public void theStateMustComeBackOnceToTheBrowserThatStarted()
+ {
+ providers.put(OAuthProvider.GITHUB, GITHUB);
+ String state = service.authorize("github", "/query", null).getState();
+ assertEquals("state", query(service.callback("github", "the-code", state, "another-browser", null, null)).get("error"));
+ // A refused state is used up as well.
+ assertEquals("state", query(service.callback("github", "the-code", state, state, null, null)).get("error"));
+ assertEquals("state", query(service.callback("github", "the-code", "made-up", "made-up", null, null)).get("error"));
+
+ state = service.authorize("github", "/query", null).getState();
+ Map denied = query(service.callback("github", null, state, state, "access_denied", null));
+ assertEquals("denied", denied.get("error"));
+ assertEquals("/query", denied.get("redirect"));
+ server.verify();
+ }
+
+ @Test
+ public void aVerifiedEmailLinksTheExistingUserAndTheTicketIsUsedOnce()
+ {
+ providers.put(OAuthProvider.GITHUB, GITHUB);
+ when(repository.findByEmail("octo@example.com")).thenReturn(Optional.of(user(7, "octo", "octo@example.com")));
+ githubAnswers("[{\"email\":\"other@example.com\",\"primary\":false,\"verified\":true},{\"email\":\"Octo@Example.com\",\"primary\":true,\"verified\":true}]");
+
+ Map result = signIn("github", "/query?tab=1");
+ server.verify();
+ assertEquals("/query?tab=1", result.get("redirect"));
+ ArgumentCaptor link = ArgumentCaptor.forClass(UserOAuthEntity.class);
+ verify(links).save(link.capture());
+ assertEquals(7L, link.getValue().getUserId());
+ assertEquals("github", link.getValue().getProvider());
+ assertEquals("42", link.getValue().getSubject());
+ verify(users, never()).saveOrUpdate(any(UserEntity.class));
+
+ CommonResponse token = service.redeem(result.get("ticket"));
+ assertTrue(token.getStatus());
+ assertEquals("jwt:octo", token.getData());
+ assertFalse(service.redeem(result.get("ticket")).getStatus());
+ }
+
+ @Test
+ public void aLinkedAccountSignsInItsUser()
+ {
+ providers.put(OAuthProvider.GITHUB, GITHUB);
+ when(links.findByProviderAndSubject("github", "42")).thenReturn(Optional.of(UserOAuthEntity.builder().id(3L).userId(9L).provider("github").subject("42").build()));
+ when(repository.findById(9L)).thenReturn(Optional.of(user(9, "linked", null)));
+ githubAnswers("[]");
+
+ assertEquals("jwt:linked", service.redeem(signIn("github", null).get("ticket")).getData());
+ verify(links, never()).save(any());
+
+ UserEntity disabled = user(9, "linked", null);
+ disabled.setActive(false);
+ when(repository.findById(9L)).thenReturn(Optional.of(disabled));
+ server.reset();
+ githubAnswers("[]");
+ assertEquals("inactive", signIn("github", null).get("error"));
+ }
+
+ @Test
+ public void aNewAccountIsCreatedOnlyWhileRegistrationIsOpen()
+ {
+ providers.put(OAuthProvider.GITHUB, GITHUB);
+ when(repository.findByUsername("octo_cat")).thenReturn(Optional.of(user(1, "octo_cat", null)));
+ when(users.saveOrUpdate(any(UserEntity.class))).thenAnswer(invocation -> {
+ UserEntity saved = invocation.getArgument(0);
+ saved.setId(11L);
+ return CommonResponse.success(saved);
+ });
+ githubAnswers("[{\"email\":\"new@example.com\",\"primary\":true,\"verified\":true}]");
+
+ assertEquals("jwt:", ((String) service.redeem(signIn("github", null).get("ticket")).getData()).substring(0, 4));
+ ArgumentCaptor created = ArgumentCaptor.forClass(UserEntity.class);
+ verify(users).saveOrUpdate(created.capture());
+ assertTrue(created.getValue().getUsername().matches("octo_cat_\\d{4}"), created.getValue().getUsername());
+ assertEquals("new@example.com", created.getValue().getEmail());
+ verify(links).save(any());
+
+ when(settings.auth()).thenReturn(AuthSettings.builder().registration(false).siteUrl(SITE).build());
+ server.reset();
+ githubAnswers("[]");
+ assertEquals("registration", signIn("github", null).get("error"));
+ }
+
+ @Test
+ public void openIdConnectUsesTheConfiguredEndpointsAndIgnoresUnverifiedEmails()
+ {
+ providers.put(OAuthProvider.OIDC, OIDC);
+ when(settings.auth()).thenReturn(AuthSettings.builder().registration(false).siteUrl(SITE).build());
+ when(repository.findByEmail("boss@example.com")).thenReturn(Optional.of(user(1, "admin", "boss@example.com")));
+ server.expect(requestTo("https://sso.example.com/token"))
+ .andRespond(withSuccess("{\"access_token\":\"sso-token\"}", MediaType.APPLICATION_JSON));
+ server.expect(requestTo("https://sso.example.com/userinfo"))
+ .andExpect(header("Authorization", "Bearer sso-token"))
+ .andRespond(withSuccess("{\"sub\":\"abc\",\"email\":\"boss@example.com\",\"email_verified\":false}", MediaType.APPLICATION_JSON));
+
+ // The unverified email does not reach the administrator account, and sign-up is closed.
+ assertEquals("registration", signIn("oidc", null).get("error"));
+ server.verify();
+ verify(repository, never()).findByEmail(anyString());
+ verify(users, never()).issue(anyString(), anyLong());
+ }
+
+ @Test
+ public void aFailingProviderEndsWithAnError()
+ {
+ providers.put(OAuthProvider.GITHUB, GITHUB);
+ server.expect(requestTo("https://github.com/login/oauth/access_token"))
+ .andRespond(withSuccess("{\"error\":\"bad_verification_code\"}", MediaType.APPLICATION_JSON));
+ assertEquals("failed", signIn("github", null).get("error"));
+ server.reset();
+ server.expect(requestTo("https://github.com/login/oauth/access_token"))
+ .andRespond(withStatus(org.springframework.http.HttpStatus.BAD_GATEWAY));
+ assertEquals("failed", signIn("github", null).get("error"));
+ verify(users, never()).issue(anyString(), anyLong());
+ }
+
+ @Test
+ public void clientSecretsAreKeptHiddenAndRequiredToTurnAProviderOn()
+ {
+ RuntimeConfigureService configures = mock(RuntimeConfigureService.class);
+ when(configures.getEffective(anyString(), anyString(), any())).thenReturn(Map.of());
+ when(configures.getEffective(eq("SYSTEM"), eq("OAUTH_GITHUB"), any())).thenReturn(GITHUB.toMap(OAuthProvider.GITHUB));
+ when(configures.save(anyString(), anyString(), anyMap(), any())).thenReturn(CommonResponse.success(null));
+ SystemSettingServiceImpl system = new SystemSettingServiceImpl(configures, mock(InitializerConfigure.class), mock(MailService.class), mock(PluginManager.class));
+
+ @SuppressWarnings("unchecked")
+ Map shown = (Map) system.settings().get("oauth");
+ assertEquals(List.of("github", "google", "oidc"), List.copyOf(shown.keySet()));
+ assertEquals("", shown.get("github").getClientSecret());
+ assertTrue(shown.get("github").isSecretSet());
+
+ AuthSettings auth = AuthSettings.builder().build();
+ // Turning on a provider without its client is refused, and nothing is saved.
+ assertFalse(system.save(auth, new MailSettings(), Map.of("google", OAuthSettings.builder().enabled(true).clientId("g").build())).getStatus());
+ assertFalse(system.save(auth, new MailSettings(), Map.of("oidc", OIDC.toBuilder().userinfoUrl("not a url").build())).getStatus());
+ assertFalse(system.save(AuthSettings.builder().siteUrl("metaforge.example.com").build(), new MailSettings(), null).getStatus());
+ verify(configures, never()).save(anyString(), anyString(), anyMap(), any());
+
+ // An empty secret keeps the stored one, and the GitHub endpoints can not be changed.
+ OAuthSettings edited = shown.get("github").toBuilder().clientId("gh-new").tokenUrl("https://attacker.example.com/token").build();
+ assertTrue(system.save(auth, new MailSettings(), Map.of("github", edited)).getStatus());
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> values = ArgumentCaptor.forClass(Map.class);
+ verify(configures).save(eq("SYSTEM"), eq("OAUTH_GITHUB"), values.capture(), any());
+ assertEquals(Map.of("enabled", "true", "clientId", "gh-new", "clientSecret", "gh-secret"), values.getValue());
+ }
+}
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/auth/SettingTestsTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/auth/SettingTestsTest.java
new file mode 100644
index 0000000000..25b09b9d55
--- /dev/null
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/auth/SettingTestsTest.java
@@ -0,0 +1,122 @@
+package org.devlive.metaforge.server.auth;
+
+import org.devlive.metaforge.fs.FsRequest;
+import org.devlive.metaforge.fs.FsResponse;
+import org.devlive.metaforge.fs.FsService;
+import org.devlive.metaforge.plugin.Plugin;
+import org.devlive.metaforge.plugin.PluginManager;
+import org.devlive.metaforge.plugin.PluginMetadata;
+import org.devlive.metaforge.plugin.PluginType;
+import org.devlive.metaforge.plugin.configure.RuntimeConfigureField;
+import org.devlive.metaforge.plugin.configure.RuntimeConfigureFieldType;
+import org.devlive.metaforge.service.initializer.InitializerConfigure;
+import org.devlive.metaforge.service.service.RuntimeConfigureService;
+import org.devlive.metaforge.service.service.impl.SystemSettingServiceImpl;
+import org.devlive.metaforge.service.settings.FsSettings;
+import org.devlive.metaforge.service.settings.MailService;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** The test buttons of System settings try the values in the form before anything is saved. */
+public class SettingTestsTest
+{
+ /** An executor whose batch size must be a number. */
+ public static class ProbeExecutor
+ extends Plugin
+ {
+ @Override
+ public PluginType getType()
+ {
+ return PluginType.EXECUTOR;
+ }
+
+ @Override
+ public List runtimeConfigures()
+ {
+ return List.of(new RuntimeConfigureField("batchSize", RuntimeConfigureFieldType.NUMBER, "1000", "Rows per batch"));
+ }
+ }
+
+ private static SystemSettingServiceImpl service(PluginManager plugins)
+ {
+ RuntimeConfigureService configures = mock(RuntimeConfigureService.class);
+ when(configures.getEffective(anyString(), anyString(), any())).thenReturn(Map.of());
+ InitializerConfigure initializer = mock(InitializerConfigure.class);
+ when(initializer.getDataHome()).thenReturn("/tmp/metaforge-data");
+ return new SystemSettingServiceImpl(configures, initializer, mock(MailService.class), plugins);
+ }
+
+ @Test
+ public void executorsCheckTheirOwnValues()
+ {
+ PluginManager plugins = mock(PluginManager.class);
+ when(plugins.getPluginInfos()).thenReturn(List.of(PluginMetadata.builder().name("Probe").type(PluginType.EXECUTOR).instance(new ProbeExecutor()).build()));
+ SystemSettingServiceImpl settings = service(plugins);
+
+ assertTrue(settings.testRuntime("EXECUTOR", "Probe", Map.of("batchSize", "500")).getStatus());
+ assertFalse(settings.testRuntime("EXECUTOR", "Probe", Map.of("batchSize", "many")).getStatus());
+ assertFalse(settings.testRuntime("EXECUTOR", "Missing", Map.of()).getStatus());
+ assertFalse(settings.testRuntime("UNKNOWN", "Probe", Map.of()).getStatus());
+ }
+
+ @Test
+ public void storageTestsWriteAndRemoveAProbeFile()
+ {
+ List calls = new ArrayList<>();
+ FsService fs = new FsService()
+ {
+ @Override
+ public FsResponse writer(FsRequest request)
+ {
+ calls.add("write " + request.getEndpoint() + " " + request.getFileName());
+ FsResponse response = new FsResponse();
+ response.setSuccessful(!"broken".equals(request.getBucket()));
+ response.setMessage("bucket refused");
+ return response;
+ }
+
+ @Override
+ public FsResponse reader(FsRequest request)
+ {
+ return new FsResponse();
+ }
+
+ @Override
+ public FsResponse delete(FsRequest request)
+ {
+ calls.add("delete " + request.getFileName());
+ return new FsResponse();
+ }
+ };
+ Plugin plugin = mock(Plugin.class);
+ when(plugin.getService(FsService.class)).thenReturn(fs);
+ PluginManager plugins = mock(PluginManager.class);
+ when(plugins.getPlugin("LocalFs")).thenReturn(Optional.of(plugin));
+ when(plugins.getPlugin("AmazonS3Fs")).thenReturn(Optional.of(plugin));
+ SystemSettingServiceImpl settings = service(plugins);
+
+ assertTrue(settings.testFs(FsSettings.builder().type("LocalFs").build()).getStatus());
+ assertEquals(2, calls.size());
+ assertTrue(calls.get(0).startsWith("write /tmp/metaforge-data/test metaforge-test/"));
+ assertTrue(calls.get(1).startsWith("delete metaforge-test/"));
+
+ FsSettings s3 = FsSettings.builder().type("AmazonS3Fs").access("key").secret("secret").endpoint("s3.example.com").bucket("broken").build();
+ assertEquals("Writing a test file failed: bucket refused", settings.testFs(s3).getMessage());
+ // An object store missing its bucket is refused before anything is written.
+ calls.clear();
+ assertFalse(settings.testFs(s3.toBuilder().bucket("").build()).getStatus());
+ assertTrue(calls.isEmpty());
+ }
+}
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/configure/PluginConfigurationTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/configure/PluginConfigurationTest.java
index 8f43dbb1b5..a80a736369 100644
--- a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/configure/PluginConfigurationTest.java
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/configure/PluginConfigurationTest.java
@@ -24,7 +24,7 @@ private Path manifest(String location)
Objects.requireNonNull(root, "TempDir root should be injected");
Path path = root.resolve(location);
Files.createDirectories(path.getParent());
- Files.writeString(path, "metaforge-executor-local=executor/metaforge-executor-local/pom.xml\n");
+ Files.writeString(path, "metaforge-executor-metaforge=executor/metaforge-executor-metaforge/pom.xml\n");
return path;
}
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/AdminApiSecurityTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/AdminApiSecurityTest.java
index 1d531dbe32..3744db2fc6 100644
--- a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/AdminApiSecurityTest.java
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/AdminApiSecurityTest.java
@@ -75,7 +75,7 @@ public void ordinaryUsersCannotCallAdministrativeApis()
public void personalEndpointsAndEditorCompletionRemainAvailable()
throws Exception
{
- for (String path : new String[] {"/api/v1/user", "/api/v1/user/menus", "/api/v1/user/checkUsername", "/api/v1/function/list/MySQL", "/api/v1/source/admin/count"}) {
+ for (String path : new String[] {"/api/v1/user", "/api/v1/user/menus", "/api/v1/user/checkUsername", "/api/v1/function/list/MySQL", "/api/v1/source/admin/count", "/api/v1/dataset/count"}) {
mvc.perform(request(HttpMethod.GET, path).servletPath(path).header("X-Test-Role", "USER")).andExpect(status().isOk());
}
String password = "/api/v1/user/changePassword";
@@ -97,6 +97,8 @@ public void anonymousAccessRequiresAuthentication()
{
String path = "/api/v1/role/list";
mvc.perform(request(HttpMethod.POST, path).servletPath(path)).andExpect(status().isUnauthorized());
+ String datasetCount = "/api/v1/dataset/count";
+ mvc.perform(request(HttpMethod.GET, datasetCount).servletPath(datasetCount)).andExpect(status().isUnauthorized());
}
@Configuration
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/DatasetCountControllerTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/DatasetCountControllerTest.java
new file mode 100644
index 0000000000..dbb54e7963
--- /dev/null
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/DatasetCountControllerTest.java
@@ -0,0 +1,103 @@
+package org.devlive.metaforge.server.controller;
+
+import org.devlive.metaforge.plugin.PluginManager;
+import org.devlive.metaforge.server.support.PersistenceContext;
+import org.devlive.metaforge.service.repository.DataSetColumnRepository;
+import org.devlive.metaforge.service.repository.DataSetRepository;
+import org.devlive.metaforge.service.security.UserDetailsService;
+import org.devlive.metaforge.service.service.FunctionService;
+import org.devlive.metaforge.service.service.impl.DataSetServiceImpl;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.List;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/** Counts real owned metadata rows without loading legacy identities, states or source references. */
+public class DatasetCountControllerTest
+{
+ private static PersistenceContext context;
+ private final PluginManager plugins = mock(PluginManager.class);
+ private MockMvc mvc;
+
+ @BeforeAll
+ public static void start() throws Exception
+ {
+ context = PersistenceContext.start("h2");
+ }
+
+ @AfterAll
+ public static void stop()
+ {
+ context.close();
+ }
+
+ @BeforeEach
+ public void setup()
+ {
+ context.jdbc().update("DELETE FROM metaforge_dataset");
+ // One missing code and an unreadable legacy state must not hide the correct count.
+ context.jdbc().update("INSERT INTO metaforge_dataset (name, code, state, source_id, user_id, active) VALUES "
+ + "('one', 'ONE', 'METADATA_SUCCESS', 999, 11, TRUE), "
+ + "('legacy', NULL, 'UNKNOWN_LEGACY_STATE', 999, 11, TRUE), "
+ + "('inactive', 'THREE', NULL, NULL, 11, FALSE), "
+ + "('other', 'OTHER', NULL, NULL, 22, TRUE), "
+ + "('unowned', 'UNOWNED', NULL, NULL, NULL, TRUE)");
+ DataSetRepository repository = context.bean(DataSetRepository.class);
+ DataSetServiceImpl service = new DataSetServiceImpl(repository, mock(DataSetColumnRepository.class), null, plugins,
+ null, null, null, null, null, null, null, null);
+ mvc = MockMvcBuilders.standaloneSetup(new DataSetController(repository, service, mock(FunctionService.class), null)).build();
+ signIn(11L);
+ }
+
+ private static void signIn(Long id)
+ {
+ UserDetailsService user = new UserDetailsService(id, "USER", "owner", "", List.of(), null);
+ SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities()));
+ }
+
+ @AfterEach
+ public void clear()
+ {
+ SecurityContextHolder.clearContext();
+ }
+
+ @Test
+ public void countsOwnedRowsIncludingMissingCodesWithoutReadingTheStoragePlugin() throws Exception
+ {
+ mvc.perform(get("/api/v1/dataset/count").param("userId", "22"))
+ .andExpect(status().isOk()).andExpect(jsonPath("$.status").value(true)).andExpect(jsonPath("$.data").value(3));
+ verifyNoInteractions(plugins);
+ }
+
+ @Test
+ public void changesScopeWithTheAuthenticatedUserAndPreservesRealZero() throws Exception
+ {
+ signIn(22L);
+ mvc.perform(get("/api/v1/dataset/count"))
+ .andExpect(status().isOk()).andExpect(jsonPath("$.status").value(true)).andExpect(jsonPath("$.data").value(1));
+ signIn(99L);
+ mvc.perform(get("/api/v1/dataset/count"))
+ .andExpect(status().isOk()).andExpect(jsonPath("$.status").value(true)).andExpect(jsonPath("$.data").value(0));
+ }
+
+ @Test
+ public void missingAuthenticationNeverCountsAllUsers() throws Exception
+ {
+ SecurityContextHolder.clearContext();
+ mvc.perform(get("/api/v1/dataset/count"))
+ .andExpect(status().isOk()).andExpect(jsonPath("$.status").value(false));
+ }
+}
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/PublicReportControllerTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/PublicReportControllerTest.java
new file mode 100644
index 0000000000..11be6ecf61
--- /dev/null
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/PublicReportControllerTest.java
@@ -0,0 +1,27 @@
+package org.devlive.metaforge.server.controller;
+
+import org.devlive.metaforge.service.service.ReportShareService;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class PublicReportControllerTest
+{
+ @Test
+ void csvQuotesSpecialCellsAndNeutralisesFormulas()
+ {
+ ReportShareService.Table table = new ReportShareService.Table(List.of("name", "note", "amount"), List.of(
+ Arrays.asList("East", "=HYPERLINK(\"http://x\")", 12),
+ Arrays.asList("We,st", "line\nbreak", -3),
+ Arrays.asList(null, "@cmd", 1.5)), false);
+ String csv = new String(PublicReportController.csv(table), StandardCharsets.UTF_8);
+ assertEquals("name,note,amount\r\n"
+ + "East,\"'=HYPERLINK(\"\"http://x\"\")\",12\r\n"
+ + "\"We,st\",\"line\nbreak\",-3\r\n"
+ + ",'@cmd,1.5\r\n", csv);
+ }
+}
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/exporter/DatabaseExportTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/exporter/DatabaseExportTest.java
new file mode 100644
index 0000000000..e172f79555
--- /dev/null
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/exporter/DatabaseExportTest.java
@@ -0,0 +1,293 @@
+package org.devlive.metaforge.server.exporter;
+
+import org.apache.commons.io.FileUtils;
+import org.devlive.metaforge.common.response.CommonResponse;
+import org.devlive.metaforge.convert.ConvertService;
+import org.devlive.metaforge.convert.model.ConvertOption;
+import org.devlive.metaforge.convert.model.ConvertRequest;
+import org.devlive.metaforge.convert.model.ConvertResponse;
+import org.devlive.metaforge.plugin.Plugin;
+import org.devlive.metaforge.plugin.PluginManager;
+import org.devlive.metaforge.service.entity.SourceEntity;
+import org.devlive.metaforge.service.exporter.ExportPlan;
+import org.devlive.metaforge.service.exporter.ExportStatements;
+import org.devlive.metaforge.service.repository.SourceRepository;
+import org.devlive.metaforge.service.service.DatabaseExportService;
+import org.devlive.metaforge.service.service.TableExportService;
+import org.devlive.metaforge.service.service.impl.DatabaseExportServiceImpl;
+import org.devlive.metaforge.spi.PluginService;
+import org.devlive.metaforge.spi.model.ManagementCapabilities;
+import org.devlive.metaforge.spi.model.ManagementCapability;
+import org.devlive.metaforge.spi.model.Response;
+import org.devlive.metaforge.spi.model.RuntimeConfigure;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.TreeMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class DatabaseExportTest
+{
+ private final ExportStatements statements = new ExportStatements(name -> "`" + name + "`", value -> "'" + value.replace("'", "''") + "'");
+
+ private static ExportPlan.Filter filter(String column, String operator, String value)
+ {
+ ExportPlan.Filter filter = new ExportPlan.Filter();
+ filter.setColumn(column);
+ filter.setOperator(operator);
+ filter.setValue(value);
+ return filter;
+ }
+
+ private static ExportPlan.TablePlan table(String name, List fields, ExportPlan.Filter... filters)
+ {
+ ExportPlan.TablePlan table = new ExportPlan.TablePlan();
+ table.setName(name);
+ table.setFields(new ArrayList<>(fields));
+ table.setFilters(new ArrayList<>(List.of(filters)));
+ return table;
+ }
+
+ @Test
+ public void selectsTakeFieldsFiltersAndPages()
+ {
+ ExportPlan.TablePlan chat = table("chat", List.of("id", "title"), filter("created", ">=", "2026-10-01"), filter("title", "IS NOT NULL", null));
+ assertEquals("SELECT `id`, `title` FROM `app`.`chat` WHERE `created` >= '2026-10-01' AND `title` IS NOT NULL LIMIT 1000 OFFSET 2000",
+ statements.select("app", chat, 1000, 2000));
+ assertEquals("SELECT * FROM `app`.`logs`", statements.select("app", table("logs", List.of()), 0, 0));
+ assertEquals("SELECT COUNT(*) FROM `app`.`chat` WHERE `title` LIKE 'it''s%'", statements.count("app", table("chat", List.of(), filter("title", "like", "it's%"))));
+ }
+
+ @Test
+ public void theSchemaDropsCreatesAndCanLeaveIndexesOut()
+ {
+ String create = "CREATE TABLE `chat` (\n `id` bigint NOT NULL,\n `user_id` bigint,\n PRIMARY KEY (`id`),\n KEY `idx_user` (`user_id`),\n"
+ + " CONSTRAINT `fk_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)\n) ENGINE=InnoDB";
+ ExportPlan.Structure structure = new ExportPlan.Structure();
+ structure.setDrop(true);
+ structure.setIndexes(false);
+ assertEquals("DROP TABLE IF EXISTS `app`.`chat`;\nCREATE TABLE `chat` (\n `id` bigint NOT NULL,\n `user_id` bigint,\n PRIMARY KEY (`id`)\n) ENGINE=InnoDB;\n",
+ statements.schema("app", "chat", create, structure));
+ structure.setIndexes(true);
+ structure.setDrop(false);
+ assertTrue(statements.schema("app", "chat", create, structure).contains("KEY `idx_user`"));
+ }
+
+ // ---- service ----
+
+ private final List executed = Collections.synchronizedList(new ArrayList<>());
+
+ /** A format writing "headers|rows|options" as a .fake file. */
+ private static final class FakeConvert
+ implements ConvertService
+ {
+ @Override
+ public List options()
+ {
+ return List.of(new ConvertOption("indent", Map.of("en", "Indent"), "2", List.of()));
+ }
+
+ @Override
+ public String extension()
+ {
+ return "fake";
+ }
+
+ @Override
+ public ConvertResponse format(ConvertRequest request)
+ {
+ return new ConvertResponse();
+ }
+
+ @Override
+ public ConvertResponse formatStream(ConvertRequest request)
+ {
+ return new ConvertResponse();
+ }
+
+ @Override
+ public ConvertResponse writer(ConvertRequest request)
+ {
+ ConvertResponse response = new ConvertResponse();
+ try {
+ FileUtils.writeStringToFile(new File(request.getPath(), request.getName() + ".fake"), request.getHeaders() + "|" + request.getColumns() + "|" + request.getOptions(),
+ StandardCharsets.UTF_8);
+ response.setSuccessful(true);
+ }
+ catch (IOException error) {
+ response.setSuccessful(false);
+ }
+ return response;
+ }
+
+ @Override
+ public ConvertResponse reader(ConvertRequest request)
+ {
+ return new ConvertResponse();
+ }
+ }
+
+ private Response answer(String sql)
+ {
+ executed.add(sql);
+ List rows = new ArrayList<>();
+ if (sql.startsWith("SELECT COUNT(*)")) {
+ rows.add(List.of(3));
+ return Response.builder().isSuccessful(true).headers(List.of("COUNT(*)")).columns(rows).build();
+ }
+ // Three rows, served a page at a time.
+ Matcher page = Pattern.compile("LIMIT (\\d+) OFFSET (\\d+)$").matcher(sql);
+ long limit = page.find() ? Long.parseLong(page.group(1)) : 3;
+ long offset = page.find(0) ? Long.parseLong(page.group(2)) : 0;
+ for (long id = offset + 1; id <= Math.min(3, offset + limit); id++) {
+ rows.add(List.of(id, "t" + id));
+ }
+ return Response.builder().isSuccessful(true).headers(List.of("id", "title")).columns(rows).build();
+ }
+
+ /** chat has id and title, logs has id, listed as every plugin lists columns. */
+ private static Response columns(String table)
+ {
+ List rows = new ArrayList<>();
+ if ("chat".equals(table)) {
+ rows.add(List.of("column", "id", "bigint", "NO", "", "", "NO"));
+ rows.add(List.of("column", "title", "varchar(64)", "YES", "", "", "NO"));
+ }
+ else if ("logs".equals(table)) {
+ rows.add(List.of("column", "id", "bigint", "NO", "", "", "NO"));
+ }
+ return Response.builder().isSuccessful(true).headers(List.of("type_name", "object_name", "object_data_type", "object_nullable", "object_default_value", "object_comment", "object_autoincrement")).columns(rows).build();
+ }
+
+ private DatabaseExportService service()
+ {
+ SourceRepository repository = mock(SourceRepository.class);
+ PluginManager manager = mock(PluginManager.class);
+ Plugin source = mock(Plugin.class);
+ Plugin convert = mock(Plugin.class);
+ PluginService adapter = new PluginService()
+ {
+ @Override
+ public ManagementCapabilities managementCapabilities()
+ {
+ return new ManagementCapabilities("relational", ManagementCapability.keys());
+ }
+
+ @Override
+ public Response execute(RuntimeConfigure configure, String content)
+ {
+ return answer(content);
+ }
+
+ @Override
+ public Response getColumns(RuntimeConfigure configure, String database, String table)
+ {
+ return columns(table);
+ }
+
+ @Override
+ public Response getTableDdl(RuntimeConfigure configure, String database, String table, String kind)
+ {
+ return Response.builder().isSuccessful(true).content("CREATE TABLE `" + table + "` (\n `id` bigint NOT NULL,\n KEY `idx` (`id`)\n)").build();
+ }
+ };
+ SourceEntity entity = new SourceEntity();
+ entity.setType("MySQL");
+ when(repository.findByCode("demo")).thenReturn(Optional.of(entity));
+ when(manager.getPlugin("MySQL")).thenReturn(Optional.of(source));
+ when(manager.getPlugin("FakeConvert")).thenReturn(Optional.of(convert));
+ when(manager.getPluginInfos()).thenReturn(List.of());
+ when(convert.getService(ConvertService.class)).thenReturn(new FakeConvert());
+ when(source.getService(PluginService.class)).thenReturn(adapter);
+ return new DatabaseExportServiceImpl(manager, repository);
+ }
+
+ private static ExportPlan plan(ExportPlan.TablePlan... tables)
+ {
+ ExportPlan plan = new ExportPlan();
+ plan.setTables(new ArrayList<>(List.of(tables)));
+ plan.setFormat("FakeConvert");
+ plan.setOptions(Map.of("indent", "4"));
+ plan.setFileName("app export");
+ plan.setBatchSize(2);
+ plan.getStructure().setDrop(true);
+ plan.getStructure().setIndexes(false);
+ return plan;
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void theCheckFlagsUnknownFieldsAndNamesTheFiles()
+ {
+ DatabaseExportService service = service();
+ CommonResponse> checked = service.check("demo", "app", plan(table("chat", List.of("id", "missing")), table("logs", List.of()), table("ghost", List.of())));
+ assertTrue(checked.getStatus(), String.valueOf(checked.getMessage()));
+ List> tables = (List>) checked.getData().get("tables");
+ assertEquals(List.of("error", "pass", "error"), tables.stream().map(row -> row.get("status")).collect(java.util.stream.Collectors.toList()));
+ assertEquals(List.of("Field missing does not exist"), tables.get(0).get("messages"));
+ assertEquals(List.of("chat.fake", "logs.fake", "ghost.fake", "schema.sql"), checked.getData().get("files"));
+ assertEquals("app_export.zip", checked.getData().get("output"));
+ assertFalse(service.execute("demo", "app", plan(table("chat", List.of("missing")))).getStatus());
+ }
+
+ @Test
+ public void theSampleIsWrittenByTheChosenFormatWithItsOptions()
+ {
+ CommonResponse> sample = service().sample("demo", "app", plan(table("chat", List.of("id", "title"))), "chat", 3);
+ assertTrue(sample.getStatus(), String.valueOf(sample.getMessage()));
+ assertEquals("[id, title]|[[1, t1], [2, t2], [3, t3]]|{indent=4}", sample.getData().get("content"));
+ assertTrue(executed.contains("SELECT `id`, `title` FROM `app`.`chat` LIMIT 3 OFFSET 0"));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void aJobReadsInPagesAndZipsEveryTableWithTheSchema()
+ throws Exception
+ {
+ DatabaseExportService service = service();
+ CommonResponse> started = service.execute("demo", "app", plan(table("chat", List.of()), table("logs", List.of())));
+ assertTrue(started.getStatus(), String.valueOf(started.getMessage()));
+ String id = String.valueOf(started.getData().get("jobId"));
+ Map job = service.job(id).getData();
+ for (int attempt = 0; attempt < 100 && "running".equals(job.get("status")); attempt++) {
+ Thread.sleep(20);
+ job = service.job(id).getData();
+ }
+ assertEquals("done", job.get("status"), String.valueOf(job.get("message")));
+ assertEquals("app_export.zip", job.get("file"));
+ Map chat = ((List>) job.get("tables")).get(0);
+ assertEquals(3L, chat.get("written"));
+ assertTrue(executed.contains("SELECT * FROM `app`.`chat` LIMIT 2 OFFSET 2"));
+
+ TableExportService.ExportFile file = service.file(id).getData();
+ assertEquals("app_export.zip", file.getName());
+ Map entries = new TreeMap<>();
+ try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(file.getContent()))) {
+ for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) {
+ entries.put(entry.getName(), new String(zip.readAllBytes(), StandardCharsets.UTF_8));
+ }
+ }
+ assertEquals(List.of("chat.fake", "logs.fake", "schema.sql"), new ArrayList<>(entries.keySet()));
+ assertEquals("[id, title]|[[1, t1], [2, t2], [3, t3]]|{indent=4}", entries.get("chat.fake"));
+ assertEquals("DROP TABLE IF EXISTS `app`.`chat`;\nCREATE TABLE `chat` (\n `id` bigint NOT NULL\n);\n\nDROP TABLE IF EXISTS `app`.`logs`;\nCREATE TABLE `logs` (\n `id` bigint NOT NULL\n);\n",
+ entries.get("schema.sql"));
+ assertTrue(service.job(id).getStatus(), "kept for two hours");
+ }
+}
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/importer/ImportTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/importer/ImportTest.java
index 92e711434a..4a213980be 100644
--- a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/importer/ImportTest.java
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/importer/ImportTest.java
@@ -127,16 +127,22 @@ public Response execute(RuntimeConfigure configure, String content)
{
executed.add(content);
List rows = new ArrayList<>();
- if (content.contains("information_schema.COLUMNS")) {
- rows.add(Arrays.asList("datacap_dataset", "id", "bigint", "NO", null, "auto_increment"));
- rows.add(Arrays.asList("datacap_dataset", "name", "varchar(128)", "NO", null, ""));
- rows.add(Arrays.asList("datacap_dataset", "owner", "varchar(32)", "NO", null, ""));
- }
- else {
- rows.add(List.of(2));
- }
+ rows.add(List.of(2));
return Response.builder().isSuccessful(true).columns(rows).build();
}
+
+ /** datacap_dataset has an auto-increment id and two required columns; other tables do not exist. */
+ @Override
+ public Response getColumns(RuntimeConfigure configure, String database, String table)
+ {
+ List rows = new ArrayList<>();
+ if ("datacap_dataset".equals(table)) {
+ rows.add(Arrays.asList("column", "id", "bigint", "NO", null, "", "YES"));
+ rows.add(Arrays.asList("column", "name", "varchar(128)", "NO", null, "", "NO"));
+ rows.add(Arrays.asList("column", "owner", "varchar(32)", "NO", null, "", "NO"));
+ }
+ return Response.builder().isSuccessful(true).headers(List.of("type_name", "object_name", "object_data_type", "object_nullable", "object_default_value", "object_comment", "object_autoincrement")).columns(rows).build();
+ }
};
SourceEntity entity = new SourceEntity();
entity.setType("MySQL");
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/migration/DatabaseMigratorTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/migration/DatabaseMigratorTest.java
index 8fac9a8f48..cfd1c98b4f 100644
--- a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/migration/DatabaseMigratorTest.java
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/migration/DatabaseMigratorTest.java
@@ -57,8 +57,11 @@ public void freshInstallCreatesSchemaAndSeedAndIsRepeatable()
JdbcTemplate jdbc = jdbc(connection);
assertEquals(20, count(jdbc, "SELECT COUNT(*) FROM metaforge_menu"));
+ // Runtime settings live in System settings, without a menu of their own.
+ assertEquals(0, count(jdbc, "SELECT COUNT(*) FROM metaforge_menu WHERE code = 'RUNTIME_CONFIGURE'"));
assertEquals(4, count(jdbc, "SELECT COUNT(*) FROM metaforge_role"));
assertEquals(28, count(jdbc, "SELECT COUNT(*) FROM metaforge_role_menu"));
+ assertEquals(count(jdbc, "SELECT id FROM metaforge_menu WHERE code = 'SYSTEM'"), count(jdbc, "SELECT parent FROM metaforge_menu WHERE code = 'SYSTEM_SETTINGS'"));
assertEquals(2, count(jdbc, "SELECT COUNT(*) FROM metaforge_function"));
assertEquals(2, count(jdbc, "SELECT COUNT(*) FROM metaforge_scheduled"));
assertEquals("系统管理员", jdbc.queryForObject("SELECT name FROM metaforge_role WHERE code = 'ADMIN'", String.class));
@@ -71,6 +74,10 @@ public void freshInstallCreatesSchemaAndSeedAndIsRepeatable()
"权限-ÄÖÜ", "src-1", "MySQL", "localhost", 3306, true, false);
assertEquals("权限-ÄÖÜ", jdbc.queryForObject("SELECT name FROM metaforge_source WHERE code = 'src-1'", String.class));
assertTrue(jdbc.queryForObject("SELECT is_public FROM metaforge_source WHERE code = 'src-1'", Boolean.class));
+
+ // new datasets default to the MetaForge executor
+ jdbc.update("INSERT INTO metaforge_dataset (name, code) VALUES ('orders', 'ds-1')");
+ assertEquals("MetaForge", jdbc.queryForObject("SELECT executor FROM metaforge_dataset WHERE code = 'ds-1'", String.class));
}
}
@@ -142,7 +149,10 @@ private void assertLegacyUpgrade(Connection legacy, Connection fresh, String sch
// history-owned join table: scheduled_id = history id, history_id = schedule id
jdbc.update("INSERT INTO metaforge_scheduled_history_relation (scheduled_id, history_id) VALUES (91, 2)");
jdbc.update("INSERT INTO metaforge_scheduled_history_source_relation (scheduled_history_id, source_id) VALUES (91, 11)");
- jdbc.update("INSERT INTO metaforge_workflow (id, name, j_from_id, j_to_id, j_user_id, code) VALUES (101, 'flow', 11, 11, 7, 'w101')");
+ jdbc.update("INSERT INTO metaforge_workflow (id, name, j_from_id, j_to_id, j_user_id, code, executor) VALUES (101, 'flow', 11, 11, 7, 'w101', 'LocalExecutor')");
+ // saved Local executor settings, plus a MetaForge row seeded next to them by an interim build
+ jdbc.update("INSERT INTO metaforge_configure (name, category, configure) VALUES ('Local', 'EXECUTOR', '{\"batchSize\":\"5000\"}')");
+ jdbc.update("INSERT INTO metaforge_configure (name, category, configure) VALUES ('MetaForge', 'EXECUTOR', '{}')");
jdbc.update("INSERT INTO metaforge_source_query (id, plugin_id, user_id, content, create_time) VALUES (111, 11, 7, 'select 1', '2026-10-28 10:11:12.345')");
DatabaseMigrator.migrate(legacy);
@@ -154,7 +164,9 @@ private void assertLegacyUpgrade(Connection legacy, Connection fresh, String sch
assertEquals(1, count(jdbc, "SELECT COUNT(*) FROM metaforge_user_role WHERE user_id = 7 AND role_id = 1"));
assertEquals(0, count(jdbc, "SELECT COUNT(*) FROM metaforge_user_role WHERE role_id = 999"));
+ // the legacy menus plus System settings (core-0007), less runtime settings (core-0009)
assertEquals(18, count(jdbc, "SELECT COUNT(*) FROM metaforge_role_menu WHERE role_id = 1"));
+ assertEquals(1, count(jdbc, "SELECT COUNT(*) FROM metaforge_menu WHERE code = 'SYSTEM_SETTINGS'"));
assertEquals("Redis", jdbc.queryForObject("SELECT source_type FROM metaforge_source WHERE id = 11", String.class));
assertTrue(jdbc.queryForObject("SELECT is_public FROM metaforge_source WHERE id = 11", Boolean.class));
assertEquals(7, count(jdbc, "SELECT user_id FROM metaforge_dataset WHERE id = 21"));
@@ -175,6 +187,11 @@ private void assertLegacyUpgrade(Connection legacy, Connection fresh, String sch
assertEquals(11, count(jdbc, "SELECT from_source_id FROM metaforge_workflow WHERE id = 101"));
assertEquals(7, count(jdbc, "SELECT user_id FROM metaforge_workflow WHERE id = 101"));
assertEquals(11, count(jdbc, "SELECT source_id FROM metaforge_source_query WHERE id = 111"));
+ // the Local executor is renamed to MetaForge, keeping its saved settings
+ assertEquals("MetaForge", jdbc.queryForObject("SELECT executor FROM metaforge_dataset WHERE id = 21", String.class));
+ assertEquals("MetaForge", jdbc.queryForObject("SELECT executor FROM metaforge_workflow WHERE id = 101", String.class));
+ assertEquals(0, count(jdbc, "SELECT COUNT(*) FROM metaforge_configure WHERE category = 'EXECUTOR' AND name = 'Local'"));
+ assertEquals("{\"batchSize\":\"5000\"}", jdbc.queryForObject("SELECT configure FROM metaforge_configure WHERE category = 'EXECUTOR' AND name = 'MetaForge'", String.class));
assertEquals("2026-10-28 10:11:12", jdbc.queryForObject("SELECT create_time FROM metaforge_source_query WHERE id = 111", java.sql.Timestamp.class)
.toLocalDateTime().toString().replace('T', ' '));
// the navigation catalogue upgrade ran, and the seed did not duplicate the legacy catalogue
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/notify/NotificationInboxTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/notify/NotificationInboxTest.java
new file mode 100644
index 0000000000..3dd1a8ab84
--- /dev/null
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/notify/NotificationInboxTest.java
@@ -0,0 +1,125 @@
+package org.devlive.metaforge.server.notify;
+
+import liquibase.Liquibase;
+import liquibase.database.DatabaseFactory;
+import liquibase.database.jvm.JdbcConnection;
+import liquibase.resource.ClassLoaderResourceAccessor;
+import org.devlive.metaforge.service.notify.NotificationInbox;
+import org.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.relational.core.dialect.H2Dialect;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+
+import java.sql.Connection;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/** Runs the real changelog on H2, then checks the inbox only ever touches the signed-in user's rows. */
+public class NotificationInboxTest
+{
+ private static final long ME = 1L;
+ private static final long OTHER = 2L;
+
+ private NamedParameterJdbcTemplate jdbc;
+ private NotificationInbox inbox;
+
+ @BeforeEach
+ public void setUp()
+ throws Exception
+ {
+ JdbcDataSource source = new JdbcDataSource();
+ source.setURL("jdbc:h2:mem:inbox-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
+ try (Connection connection = source.getConnection()) {
+ new Liquibase("db/changelog/db.changelog-master.xml", new ClassLoaderResourceAccessor(),
+ DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection))).update("");
+ }
+ jdbc = new NamedParameterJdbcTemplate(source);
+ inbox = new NotificationInbox(jdbc, H2Dialect.INSTANCE);
+ insert(ME, "a", "DATASET_SYNC_FAILED", "数据集「订单」同步失败", "x".repeat(1500), false, "2026-11-05 10:00:00");
+ insert(ME, "b", "TEST", "通知测试", "这是一条测试通知", false, "2026-11-05 11:00:00");
+ insert(ME, "c", "SYNCDATA", "旧的同步通知", "历史数据 100%_done", true, "2026-11-04 09:00:00");
+ insert(ME, "d", "PASSWORD_CHANGED", "登录密码已修改", "你的登录密码已修改", true, "2026-11-03 09:00:00");
+ insert(OTHER, "e", "TEST", "别人的通知", "不可见", false, "2026-11-05 12:00:00");
+ }
+
+ private void insert(long user, String code, String type, String name, String content, boolean read, String time)
+ {
+ jdbc.update("INSERT INTO metaforge_notification (code, name, content, type, is_read, user_id, create_time, entity_exists) "
+ + "VALUES (:code, :name, :content, :type, :read, :user, :time, :exists)",
+ new MapSqlParameterSource("code", code).addValue("name", name).addValue("content", content).addValue("type", type)
+ .addValue("read", read).addValue("user", user).addValue("time", Timestamp.valueOf(time)).addValue("exists", true));
+ }
+
+ private List codes(NotificationInbox.Query query)
+ {
+ return inbox.page(ME, query).getItems().stream().map(NotificationInbox.Item::getCode).collect(Collectors.toList());
+ }
+
+ @Test
+ public void listsOnlyMineNewestFirstAndFiltersByStateCategoryAndWords()
+ {
+ NotificationInbox.Query all = new NotificationInbox.Query();
+ Assertions.assertEquals(List.of("b", "a", "c", "d"), codes(all));
+ Assertions.assertEquals(1500, inbox.page(ME, all).getItems().get(1).getContent().length());
+
+ NotificationInbox.Query unread = new NotificationInbox.Query();
+ unread.setRead(false);
+ Assertions.assertEquals(List.of("b", "a"), codes(unread));
+
+ NotificationInbox.Query dataset = new NotificationInbox.Query();
+ dataset.setCategory("DATASET");
+ Assertions.assertEquals(List.of("a", "c"), codes(dataset), "old operation names count as datasets");
+
+ NotificationInbox.Query system = new NotificationInbox.Query();
+ system.setCategory("SYSTEM");
+ Assertions.assertEquals(List.of("b"), codes(system));
+
+ NotificationInbox.Query words = new NotificationInbox.Query();
+ words.setKeyword("%_");
+ Assertions.assertEquals(List.of("c"), codes(words), "wildcards are matched literally");
+
+ NotificationInbox.Query paged = new NotificationInbox.Query();
+ paged.setSize(3);
+ paged.setPage(2);
+ Assertions.assertEquals(List.of("d"), codes(paged));
+ Assertions.assertEquals(4, inbox.page(ME, paged).getTotal());
+ }
+
+ @Test
+ public void summaryCountsByCategory()
+ {
+ NotificationInbox.Summary summary = inbox.summary(ME);
+ Assertions.assertEquals(4, summary.getTotal());
+ Assertions.assertEquals(2, summary.getUnread());
+ Assertions.assertEquals(Long.valueOf(2), summary.getCategories().get("DATASET"));
+ Assertions.assertEquals(Long.valueOf(1), summary.getCategories().get("SYSTEM"));
+ Assertions.assertEquals(Long.valueOf(1), summary.getCategories().get("ACCOUNT"));
+ Assertions.assertEquals(Long.valueOf(0), summary.getCategories().get("PLUGIN"));
+ }
+
+ @Test
+ public void readStateAndRemovalNeverReachAnotherUsersNotifications()
+ {
+ Assertions.assertFalse(inbox.mark(ME, "e", true));
+ Assertions.assertFalse(inbox.delete(ME, "e"));
+ Assertions.assertTrue(inbox.find(ME, "e").isEmpty());
+
+ Assertions.assertTrue(inbox.mark(ME, "c", false));
+ Assertions.assertEquals(3, inbox.unread(ME));
+ Assertions.assertEquals(1, inbox.markAllRead(ME, "DATASET") - 1);
+ Assertions.assertEquals(1, inbox.unread(ME));
+ Assertions.assertEquals(1, inbox.markAllRead(ME, null));
+ Assertions.assertEquals(0, inbox.unread(ME));
+ Assertions.assertEquals(1, inbox.unread(OTHER));
+
+ Assertions.assertTrue(inbox.delete(ME, "a"));
+ Assertions.assertEquals(3, inbox.deleteRead(ME));
+ Assertions.assertEquals(0, inbox.summary(ME).getTotal());
+ Assertions.assertEquals(1, inbox.summary(OTHER).getTotal());
+ }
+}
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/persistence/EntityMappingTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/persistence/EntityMappingTest.java
index 49fe93c8f5..5bc15d9b91 100644
--- a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/persistence/EntityMappingTest.java
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/persistence/EntityMappingTest.java
@@ -96,7 +96,7 @@ else if (!columns.contains(property.getColumnName().getReference().toLowerCase(L
}
});
}
- assertEquals(21, entities);
+ assertEquals(22, entities);
assertEquals(List.of(), problems);
}
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/DashboardAccessTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/DashboardAccessTest.java
new file mode 100644
index 0000000000..b4c249ab4e
--- /dev/null
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/DashboardAccessTest.java
@@ -0,0 +1,116 @@
+package org.devlive.metaforge.server.service;
+
+import org.devlive.metaforge.service.entity.DashboardEntity;
+import org.devlive.metaforge.service.entity.ReportEntity;
+import org.devlive.metaforge.service.repository.DashboardRepository;
+import org.devlive.metaforge.service.repository.ReportRepository;
+import org.devlive.metaforge.service.repository.UserRepository;
+import org.devlive.metaforge.service.repository.jdbc.LinkRepository;
+import org.devlive.metaforge.service.security.UserDetailsService;
+import org.devlive.metaforge.service.service.impl.DashboardServiceImpl;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+public class DashboardAccessTest
+{
+ private final DashboardRepository dashboards = mock(DashboardRepository.class);
+ private final ReportRepository reports = mock(ReportRepository.class);
+ private final LinkRepository links = mock(LinkRepository.class);
+ private final DashboardServiceImpl service = new DashboardServiceImpl(dashboards, reports, links);
+
+ @BeforeEach
+ public void authenticate()
+ {
+ UserDetailsService user = new UserDetailsService(1L, "USER", "owner", "", List.of(), null);
+ SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities()));
+ }
+
+ @AfterEach
+ public void clear()
+ {
+ SecurityContextHolder.clearContext();
+ }
+
+ @Test
+ public void cannotOverwriteOrDeleteAnotherOwnersDashboard()
+ {
+ DashboardEntity dashboard = DashboardEntity.builder().id(5L).code("DASH").userId(2L).build();
+ when(dashboards.findByCode("DASH")).thenReturn(Optional.of(dashboard));
+ when(dashboards.findById(5L)).thenReturn(Optional.of(dashboard));
+
+ assertFalse(service.saveOrUpdate(dashboards, DashboardEntity.builder().id(5L).build()).getStatus());
+ assertFalse(service.getByCode(dashboards, "DASH").getStatus());
+ assertFalse(service.deleteByCode(dashboards, "DASH").getStatus());
+ verify(dashboards, never()).save(any());
+ verify(dashboards, never()).delete(any());
+ verifyNoInteractions(reports, links);
+ }
+
+ @Test
+ public void onlyOwnedReportsCanBeLinked()
+ {
+ ReportEntity requested = ReportEntity.builder().code("CHART").build();
+ DashboardEntity draft = DashboardEntity.builder().reports(Set.of(requested)).build();
+ when(reports.findByCode("CHART")).thenReturn(Optional.of(ReportEntity.builder().id(8L).userId(2L).build()));
+ assertFalse(service.saveOrUpdate(dashboards, draft).getStatus());
+ verify(dashboards, never()).save(any());
+ verifyNoInteractions(links);
+
+ when(reports.findByCode("CHART")).thenReturn(Optional.of(ReportEntity.builder().id(9L).userId(1L).build()));
+ when(dashboards.save(draft)).thenReturn(DashboardEntity.builder().id(7L).build());
+ assertTrue(service.saveOrUpdate(dashboards, draft).getStatus());
+ verify(links).replaceDashboardReports(7L, List.of(9L));
+ }
+
+ @Test
+ public void onlyOwnPublishedDataScreensBecomeTheHomePage()
+ {
+ UserRepository users = mock(UserRepository.class);
+ service.setUsers(users);
+ when(dashboards.findByCode("FOREIGN")).thenReturn(Optional.of(DashboardEntity.builder().code("FOREIGN").userId(2L)
+ .configure("{\"version\":3,\"draft\":{},\"published\":{}}").build()));
+ when(dashboards.findByCode("DRAFT")).thenReturn(Optional.of(DashboardEntity.builder().code("DRAFT").userId(1L)
+ .configure("{\"version\":3,\"draft\":{}}").build()));
+ when(dashboards.findByCode("SCREEN")).thenReturn(Optional.of(DashboardEntity.builder().id(3L).code("SCREEN").userId(1L)
+ .configure("{\"version\":3,\"draft\":{},\"published\":{}}").build()));
+
+ assertFalse(service.setHomePage("FOREIGN").getStatus());
+ assertFalse(service.setHomePage("DRAFT").getStatus());
+ assertFalse(service.setHomePage("MISSING").getStatus());
+ verify(users, never()).updateHomePage(any(), any());
+
+ assertTrue(service.setHomePage("SCREEN").getStatus());
+ verify(users).updateHomePage(1L, "SCREEN");
+ assertTrue(service.setHomePage(null).getStatus());
+ verify(users).updateHomePage(1L, null);
+
+ assertTrue(service.deleteByCode(dashboards, "SCREEN").getStatus());
+ verify(users).clearHomePage("SCREEN");
+ }
+
+ @Test
+ public void publishedLayoutsAreRecognisedInBothDocumentFormats()
+ {
+ assertTrue(DashboardServiceImpl.published("{\"version\":3,\"draft\":{},\"published\":{\"boards\":{}}}"));
+ assertFalse(DashboardServiceImpl.published("{\"version\":3,\"draft\":{}}"));
+ assertTrue(DashboardServiceImpl.published("[{\"original\":{\"code\":\"R\"}}]"));
+ assertFalse(DashboardServiceImpl.published(null));
+ assertFalse(DashboardServiceImpl.published("not json"));
+ }
+}
diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/DashboardShareTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/DashboardShareTest.java
new file mode 100644
index 0000000000..ddfdf62fd5
--- /dev/null
+++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/DashboardShareTest.java
@@ -0,0 +1,368 @@
+package org.devlive.metaforge.server.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.devlive.metaforge.common.response.CommonResponse;
+import org.devlive.metaforge.server.support.PersistenceContext;
+import org.devlive.metaforge.service.body.DashboardShareBody;
+import org.devlive.metaforge.service.dashboard.share.DashboardViewerDocument;
+import org.devlive.metaforge.service.entity.DashboardEntity;
+import org.devlive.metaforge.service.entity.ReportEntity;
+import org.devlive.metaforge.service.entity.UserEntity;
+import org.devlive.metaforge.service.enums.SharePermission;
+import org.devlive.metaforge.service.persistence.FilterQuery;
+import org.devlive.metaforge.service.persistence.Persistence;
+import org.devlive.metaforge.service.persistence.References;
+import org.devlive.metaforge.service.repository.DashboardRepository;
+import org.devlive.metaforge.service.repository.ReportRepository;
+import org.devlive.metaforge.service.repository.RoleRepository;
+import org.devlive.metaforge.service.repository.UserRepository;
+import org.devlive.metaforge.service.repository.jdbc.DashboardShareRepository;
+import org.devlive.metaforge.service.repository.jdbc.LinkRepository;
+import org.devlive.metaforge.service.security.UserDetailsService;
+import org.devlive.metaforge.service.service.ReportService;
+import org.devlive.metaforge.service.service.impl.DashboardServiceImpl;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** Sharing data screens: who sees what, what they may do, and what reaches them. */
+public class DashboardShareTest
+{
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ // Owner user 2 publishes a screen with a container (row) holding a chart, and a separate table.
+ private static final String SCREEN = "{\"version\":3,"
+ + "\"draft\":{\"mode\":\"wall\",\"boards\":{\"wall\":{\"widgets\":[{\"id\":\"secret\",\"reportCode\":\"DRAFT_ONLY\"}]}}},"
+ + "\"published\":{\"mode\":\"wall\",\"boards\":{\"wall\":{\"widgets\":["
+ + "{\"id\":\"row\"},{\"id\":\"chart\",\"parentId\":\"row\",\"reportCode\":\"SALES\"},{\"id\":\"table\",\"reportCode\":\"COSTS\"}]}}}}";
+
+ private final DashboardRepository dashboards = mock(DashboardRepository.class);
+ private final ReportRepository reportRepository = mock(ReportRepository.class);
+ private final LinkRepository links = mock(LinkRepository.class);
+ private final UserRepository users = mock(UserRepository.class);
+ private final RoleRepository roles = mock(RoleRepository.class);
+ private final DashboardShareRepository shares = mock(DashboardShareRepository.class);
+ private final ReportService reports = mock(ReportService.class);
+ private final DashboardServiceImpl service = new DashboardServiceImpl(dashboards, reportRepository, links);
+ private final DashboardEntity screen = DashboardEntity.builder().id(9L).code("SCREEN").name("Sales").userId(2L).configure(SCREEN).build();
+
+ @BeforeEach
+ public void setup()
+ {
+ // Persistence is a static holder other tests may leave pointing at a closed database; each test gets its own.
+ new Persistence(mock(FilterQuery.class), mock(References.class));
+ service.setUsers(users);
+ service.setRoles(roles);
+ service.setShares(shares);
+ service.setReports(reports);
+ signIn(1L);
+ when(dashboards.findByCode("SCREEN")).thenReturn(Optional.of(screen));
+ when(dashboards.findById(9L)).thenReturn(Optional.of(screen));
+ when(links.reportIdsOfDashboard(9L)).thenReturn(List.of(31L, 32L));
+ when(links.roleIdsOfUser(1L)).thenReturn(List.of(7L));
+ ReportEntity sales = ReportEntity.builder().id(31L).code("SALES").userId(2L).build();
+ ReportEntity costs = ReportEntity.builder().id(32L).code("COSTS").userId(2L).build();
+ when(reportRepository.findAllById(any())).thenReturn(List.of(sales, costs));
+ when(reportRepository.findByCode("SALES")).thenReturn(Optional.of(sales));
+ when(reportRepository.findByCode("COSTS")).thenReturn(Optional.of(costs));
+ UserEntity owner = new UserEntity();
+ owner.setId(2L);
+ owner.setUsername("owner");
+ when(users.findById(2L)).thenReturn(Optional.of(owner));
+ }
+
+ @AfterEach
+ public void clear()
+ {
+ SecurityContextHolder.clearContext();
+ }
+
+ private static void signIn(Long id)
+ {
+ UserDetailsService user = new UserDetailsService(id, "USER", "user" + id, "", List.of(), null);
+ SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities()));
+ }
+
+ private void grantRole(String permission, String widgets)
+ {
+ DashboardShareRepository.Share share = new DashboardShareRepository.Share();
+ share.setDashboardId(9L);
+ share.setWidgets(widgets);
+ when(shares.find(9L)).thenReturn(Optional.of(share));
+ when(shares.members(9L)).thenReturn(List.of(new DashboardShareRepository.Member(DashboardShareRepository.ROLE, 7L, permission)));
+ }
+
+ @Test
+ public void viewersGetOnlyThePublishedChosenWidgetsAndTheirReports() throws Exception
+ {
+ grantRole("VIEW", "[\"row\"]");
+ CommonResponse response = service.getByCode(dashboards, "SCREEN");
+ assertTrue(response.getStatus());
+ DashboardEntity seen = response.getData();
+ assertEquals("VIEW", seen.getPermission());
+ assertEquals("owner", seen.getOwnerName());
+ JsonNode document = MAPPER.readTree(seen.getConfigure());
+ // The draft never reaches a viewer: both parts hold the published design.
+ assertEquals(document.get("published"), document.get("draft"));
+ assertFalse(seen.getConfigure().contains("secret"));
+ List