feat: supports subdirectory deployment (#3731)

* feat: supports subdirectory deployment

* feat: auto publicPath

* fix: buildIndexHtml

* fix: format

* fix: regexp

* fix: test error

* fix: nocobase.conf

* fix: path

* fix: nocobase.conf

* fix: bugs

* fix: resourcer prefix

* fix: cas
This commit is contained in:
chenos 2024-03-16 20:01:34 +08:00 committed by GitHub
parent 126f60c959
commit b359f9eac6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 380 additions and 139 deletions

View File

@ -30,7 +30,6 @@ RUN ARCH= && dpkgArch="$(dpkg --print-architecture)" \
&& apt-get update && apt-get install -y nginx
RUN rm -rf /etc/nginx/sites-enabled/default
COPY ./nocobase.conf /etc/nginx/sites-enabled/nocobase.conf
COPY --from=builder /app/nocobase.tar.gz /app/nocobase.tar.gz
WORKDIR /app/nocobase

View File

@ -1,9 +1,6 @@
#!/bin/sh
set -e
nginx
echo 'nginx started';
if [ ! -d "/app/nocobase" ]; then
mkdir nocobase
fi
@ -14,6 +11,13 @@ if [ ! -f "/app/nocobase/package.json" ]; then
touch /app/nocobase/node_modules/@nocobase/app/dist/client/index.html
fi
cd /app/nocobase && yarn nocobase create-nginx-conf
rm -rf /etc/nginx/sites-enabled/nocobase.conf
ln -s /app/nocobase/storage/nocobase.conf /etc/nginx/sites-enabled/nocobase.conf
nginx
echo 'nginx started';
cd /app/nocobase && yarn start --quickstart
# Run command with node if the first argument contains a "-" or is not a system command. The last

View File

@ -19,16 +19,29 @@ indexGenerator.generate();
export default defineConfig({
title: 'Loading...',
devtool: process.env.NODE_ENV === 'development' ? 'source-map' : false,
favicons: ['/favicon/favicon.ico'],
favicons: [`{{env.APP_PUBLIC_PATH}}favicon/favicon.ico`],
metas: [{ name: 'viewport', content: 'initial-scale=0.1' }],
links: [
{ rel: 'apple-touch-icon', size: '180x180', ref: '/favicon/apple-touch-icon.png' },
{ rel: 'icon', type: 'image/png', size: '32x32', ref: '/favicon/favicon-32x32.png' },
{ rel: 'icon', type: 'image/png', size: '16x16', ref: '/favicon/favicon-16x16.png' },
{ rel: 'manifest', href: '/favicon/site.webmanifest' },
{ rel: 'stylesheet', href: '/global.css' },
{ rel: 'apple-touch-icon', size: '180x180', ref: `{{env.APP_PUBLIC_PATH}}favicon/apple-touch-icon.png` },
{ rel: 'icon', type: 'image/png', size: '32x32', ref: `{{env.APP_PUBLIC_PATH}}favicon/favicon-32x32.png` },
{ rel: 'icon', type: 'image/png', size: '16x16', ref: `{{env.APP_PUBLIC_PATH}}favicon/favicon-16x16.png` },
{ rel: 'manifest', href: `{{env.APP_PUBLIC_PATH}}favicon/site.webmanifest` },
{ rel: 'stylesheet', href: `{{env.APP_PUBLIC_PATH}}global.css` },
],
headScripts: [
{
src: `{{env.APP_PUBLIC_PATH}}browser-checker.js`,
},
{
content: `
window['__webpack_public_path__'] = '{{env.APP_PUBLIC_PATH}}';
window['__nocobase_api_base_url__'] = '{{env.API_BASE_URL}}';
window['__nocobase_public_path__'] = '{{env.APP_PUBLIC_PATH}}';
window['__nocobase_ws_url__'] = '{{env.WS_URL}}';
window['__nocobase_ws_path__'] = '{{env.WS_PATH}}';
`,
},
],
headScripts: ['/browser-checker.js'],
outputPath: path.resolve(__dirname, '../dist/client'),
hash: true,
alias: {
@ -41,6 +54,7 @@ export default defineConfig({
proxy: {
...umiConfig.proxy,
},
publicPath: 'auto',
fastRefresh: false, // 热更新会导致 Context 丢失,不开启
mfsu: false,
esbuildMinifyIIFE: true,

View File

@ -4,10 +4,18 @@ import devDynamicImport from '../.plugins/index';
export const app = new Application({
apiClient: {
baseURL: process.env.API_BASE_URL,
// @ts-ignore
baseURL: window['__nocobase_api_base_url__'] || '/api/',
},
// @ts-ignore
publicPath: window['__nocobase_public_path__'] || '/',
plugins: [NocoBaseClientPresetPlugin],
ws: true,
ws: {
// @ts-ignore
url: window['__nocobase_ws_url__'] || '',
// @ts-ignore
basename: window['__nocobase_ws_path__'] || '/ws',
},
loadRemotePlugins: true,
devDynamicImport,
});

View File

@ -0,0 +1,89 @@
log_format apm '"$time_local" client=$remote_addr '
'method=$request_method request="$request" '
'request_length=$request_length '
'status=$status bytes_sent=$bytes_sent '
'body_bytes_sent=$body_bytes_sent '
'referer=$http_referer '
'user_agent="$http_user_agent" '
'upstream_addr=$upstream_addr '
'upstream_status=$upstream_status '
'request_time=$request_time '
'upstream_response_time=$upstream_response_time '
'upstream_connect_time=$upstream_connect_time '
'upstream_header_time=$upstream_header_time';
server {
listen 80;
server_name _;
root {{cwd}}/node_modules/@nocobase/app/dist/client;
index index.html;
client_max_body_size 1000M;
access_log /var/log/nginx/nocobase.log apm;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# 不缓存 HTML 文件
# location ~ \.html$ {
# if_modified_since off;
# expires off;
# etag off;
# }
# # 缓存 JavaScript 和 CSS 文件
# location ~* \.(js|css)$ {
# expires 365d;
# add_header Cache-Control "public";
# }
location {{publicPath}}storage/uploads/ {
alias {{cwd}}/storage/uploads/;
add_header Cache-Control "public";
access_log off;
autoindex off;
}
location {{publicPath}} {
alias {{cwd}}/node_modules/@nocobase/app/dist/client/;
try_files $uri $uri/ /index.html;
add_header Last-Modified $date_gmt;
add_header Cache-Control 'no-store, no-cache';
if_modified_since off;
expires off;
etag off;
}
location ^~ {{publicPath}}api/ {
proxy_pass http://127.0.0.1:{{apiPort}}{{publicPath}}api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_connect_timeout 600;
proxy_send_timeout 600;
proxy_read_timeout 600;
send_timeout 600;
}
location ^~ {{publicPath}}static/plugins/ {
proxy_pass http://127.0.0.1:{{apiPort}}{{publicPath}}static/plugins/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_connect_timeout 600;
proxy_send_timeout 600;
proxy_read_timeout 600;
send_timeout 600;
}
location {{publicPath}}ws {
proxy_pass http://127.0.0.1:{{apiPort}}{{publicPath}}ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
}

View File

@ -1,6 +1,6 @@
const { resolve } = require('path');
const { Command } = require('commander');
const { run, nodeCheck, isPackageValid } = require('../util');
const { run, nodeCheck, isPackageValid, buildIndexHtml } = require('../util');
/**
*
@ -31,5 +31,6 @@ module.exports = (cli) => {
!options.dts ? '--no-dts' : '',
options.sourcemap ? '--sourcemap' : '',
]);
buildIndexHtml(true);
});
};

View File

@ -0,0 +1,21 @@
const { resolve } = require('path');
const { Command } = require('commander');
const { readFileSync, writeFileSync } = require('fs');
/**
*
* @param {Command} cli
*/
module.exports = (cli) => {
cli.command('create-nginx-conf').action(async (name, options) => {
const file = resolve(__dirname, '../../nocobase.conf.tpl');
const data = readFileSync(file, 'utf-8');
const replaced = data
.replace(/\{\{cwd\}\}/g, '/app/nocobase')
.replace(/\{\{publicPath\}\}/g, process.env.APP_PUBLIC_PATH)
.replace(/\{\{apiPort\}\}/g, process.env.APP_PORT);
const targetFile = resolve(process.cwd(), 'storage', 'nocobase.conf');
writeFileSync(targetFile, replaced);
});
};

View File

@ -8,6 +8,7 @@ const { isPackageValid, generateAppDir } = require('../util');
module.exports = (cli) => {
generateAppDir();
require('./global')(cli);
require('./create-nginx-conf')(cli);
require('./build')(cli);
require('./tar')(cli);
require('./dev')(cli);

View File

@ -180,6 +180,7 @@ exports.generateAppDir = function generateAppDir() {
} else {
process.env.APP_PACKAGE_ROOT = appPkgPath;
}
buildIndexHtml();
};
exports.genTsConfigPaths = function genTsConfigPaths() {
@ -257,6 +258,30 @@ function parseEnv(name) {
}
}
function buildIndexHtml(force = false) {
const file = `${process.env.APP_PACKAGE_ROOT}/dist/client/index.html`;
if (!fs.existsSync(file)) {
return;
}
const tpl = `${process.env.APP_PACKAGE_ROOT}/dist/client/index.html.tpl`;
if (force && fs.existsSync(tpl)) {
fs.rmSync(tpl);
}
if (!fs.existsSync(tpl)) {
fs.copyFileSync(file, tpl);
}
const data = fs.readFileSync(tpl, 'utf-8');
const replacedData = data
.replace(/\{\{env.APP_PUBLIC_PATH\}\}/g, process.env.APP_PUBLIC_PATH)
.replace(/\{\{env.API_BASE_URL\}\}/g, process.env.API_BASE_URL || process.env.API_BASE_PATH)
.replace(/\{\{env.WS_URL\}\}/g, process.env.WEBSOCKET_URL || '')
.replace(/\{\{env.WS_PATH\}\}/g, process.env.WS_PATH)
.replace('src="/umi.', `src="${process.env.APP_PUBLIC_PATH}umi.`);
fs.writeFileSync(file, replacedData, 'utf-8');
}
exports.buildIndexHtml = buildIndexHtml;
exports.initEnv = function initEnv() {
const env = {
APP_ENV: 'development',
@ -280,7 +305,10 @@ exports.initEnv = function initEnv() {
PLAYWRIGHT_AUTH_FILE: resolve(process.cwd(), 'storage/playwright/.auth/admin.json'),
CACHE_DEFAULT_STORE: 'memory',
CACHE_MEMORY_MAX: 2000,
PLUGIN_STATICS_PATH: '/static/plugins/',
LOGGER_BASE_PATH: 'storage/logs',
APP_SERVER_BASE_URL: '',
APP_PUBLIC_PATH: '/',
};
if (
@ -319,4 +347,16 @@ exports.initEnv = function initEnv() {
process.env[key] = env[key];
}
}
if (process.env.APP_PUBLIC_PATH) {
const publicPath = process.env.APP_PUBLIC_PATH.replace(/\/$/g, '');
const keys = ['API_BASE_PATH', 'WS_PATH', 'PLUGIN_STATICS_PATH'];
for (const key of keys) {
process.env[key] = publicPath + process.env[key];
}
}
if (process.env.APP_SERVER_BASE_URL && !process.env.API_BASE_URL) {
process.env.API_BASE_URL = process.env.APP_SERVER_BASE_URL + process.env.API_BASE_PATH;
}
};

View File

@ -1,4 +1,4 @@
import { APIClient as APIClientSDK } from '@nocobase/sdk';
import { APIClient as APIClientSDK, getSubAppName } from '@nocobase/sdk';
import { Result } from 'ahooks/es/useRequest/src/types';
import { notification } from 'antd';
import React from 'react';
@ -39,9 +39,9 @@ export class APIClient extends APIClientSDK {
interceptors() {
this.axios.interceptors.request.use((config) => {
config.headers['X-With-ACL-Meta'] = true;
const match = location.pathname.match(/^\/apps\/([^/]*)\//);
if (match) {
config.headers['X-App'] = match[1];
const appName = this.app ? getSubAppName(this.app.getPublicPath()) : null;
if (appName) {
config.headers['X-App'] = appName;
}
return config;
});

View File

@ -9,14 +9,14 @@ import { createRoot } from 'react-dom/client';
import { I18nextProvider } from 'react-i18next';
import { Link, NavLink, Navigate } from 'react-router-dom';
import { APIClient, APIClientProvider } from '../api-client';
import { CSSVariableProvider } from '../css-variable';
import { AntdAppProvider, GlobalThemeProvider } from '../global-theme';
import { i18n } from '../i18n';
import { PluginManager, PluginType } from './PluginManager';
import { PluginSettingOptions, PluginSettingsManager } from './PluginSettingsManager';
import { ComponentTypeAndString, RouterManager, RouterOptions } from './RouterManager';
import { WebSocketClient, WebSocketClientOptions } from './WebSocketClient';
import { APIClient, APIClientProvider } from '../api-client';
import { i18n } from '../i18n';
import { AppComponent, BlankComponent, defaultAppComponents } from './components';
import { SchemaInitializer, SchemaInitializerManager } from './schema-initializer';
import * as schemaInitializerComponents from './schema-initializer/components';
@ -25,10 +25,10 @@ import { compose, normalizeContainer } from './utils';
import { defineGlobalDeps } from './utils/globalDeps';
import { getRequireJs } from './utils/requirejs';
import { type DataSourceManagerOptions, DataSourceManager } from '../data-source/data-source/DataSourceManager';
import { DataSourceApplicationProvider } from '../data-source/components/DataSourceApplicationProvider';
import { CollectionField } from '../data-source/collection-field/CollectionField';
import { DataSourceApplicationProvider } from '../data-source/components/DataSourceApplicationProvider';
import { DataBlockProvider } from '../data-source/data-block/DataBlockProvider';
import { DataSourceManager, type DataSourceManagerOptions } from '../data-source/data-source/DataSourceManager';
import { AppSchemaComponentProvider } from './AppSchemaComponentProvider';
import type { Plugin } from './Plugin';
@ -44,6 +44,7 @@ export type DevDynamicImport = (packageName: string) => Promise<{ default: typeo
export type ComponentAndProps<T = any> = [ComponentType, T];
export interface ApplicationOptions {
name?: string;
publicPath?: string;
apiClient?: APIClientOptions | APIClient;
ws?: WebSocketClientOptions | boolean;
i18n?: i18next;
@ -116,9 +117,10 @@ export class Application {
this.addReactRouterComponents();
this.addProviders(options.providers || []);
this.ws = new WebSocketClient(options.ws);
this.ws.app = this;
this.pluginSettingsManager = new PluginSettingsManager(options.pluginSettings, this);
this.addRoutes();
this.name = this.options.name || getSubAppName() || 'main';
this.name = this.options.name || getSubAppName(options.publicPath) || 'main';
}
private initRequireJs() {
@ -157,6 +159,18 @@ export class Application {
});
}
getOptions() {
return this.options;
}
getPublicPath() {
return this.options.publicPath || '/';
}
getRouteUrl(pathname: string) {
return this.options.publicPath.replace(/\/$/g, '') + pathname;
}
getCollectionManager(dataSource?: string) {
return this.dataSourceManager.getDataSource(dataSource)?.collectionManager;
}

View File

@ -1,4 +1,4 @@
import { set, get } from 'lodash';
import { get, set } from 'lodash';
import React, { ComponentType } from 'react';
import {
BrowserRouter,
@ -10,8 +10,8 @@ import {
RouteObject,
useRoutes,
} from 'react-router-dom';
import { BlankComponent, RouterContextCleaner } from './components';
import { Application } from './Application';
import { BlankComponent, RouterContextCleaner } from './components';
export interface BrowserRouterOptions extends Omit<BrowserRouterProps, 'children'> {
type?: 'browser';
@ -98,6 +98,10 @@ export class RouterManager {
this.options.type = type;
}
getBasename() {
return this.options.basename;
}
setBasename(basename: string) {
this.options.basename = basename;
}

View File

@ -1,34 +1,13 @@
import { define, observable } from '@formily/reactive';
import { getSubAppName } from '@nocobase/sdk';
export const getWebSocketURL = () => {
if (!process.env.API_BASE_URL) {
return;
}
const subApp = getSubAppName();
const queryString = subApp ? `?__appName=${subApp}` : '';
const wsPath = process.env.WS_PATH || '/ws';
if (process.env.WEBSOCKET_URL) {
const url = new URL(process.env.WEBSOCKET_URL);
if (url.hostname === 'localhost') {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
return `${protocol}://${location.hostname}:${url.port}${wsPath}${queryString}`;
}
return `${process.env.WEBSOCKET_URL}${queryString}`;
}
try {
const url = new URL(process.env.API_BASE_URL);
return `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}${wsPath}${queryString}`;
} catch (error) {
return `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${wsPath}${queryString}`;
}
};
import { Application } from './Application';
export type WebSocketClientOptions = {
reconnectInterval?: number;
reconnectAttempts?: number;
pingInterval?: number;
url?: string;
basename?: string;
protocols?: string | string[];
onServerDown?: any;
};
@ -38,6 +17,7 @@ export class WebSocketClient {
protected _reconnectTimes = 0;
protected events = [];
protected options: WebSocketClientOptions;
app: Application;
enabled: boolean;
connected = false;
serverDown = false;
@ -57,6 +37,34 @@ export class WebSocketClient {
});
}
getURL() {
if (!this.app) {
return;
}
const options = this.app.getOptions();
const apiBaseURL = options?.apiClient?.['baseURL'];
if (!apiBaseURL) {
return;
}
const subApp = getSubAppName(this.app.getPublicPath());
const queryString = subApp ? `?__appName=${subApp}` : '';
const wsPath = this.options.basename || '/ws';
if (this.options.url) {
const url = new URL(this.options.url);
if (url.hostname === 'localhost') {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
return `${protocol}://${location.hostname}:${url.port}${wsPath}${queryString}`;
}
return `${this.options.url}${queryString}`;
}
try {
const url = new URL(apiBaseURL);
return `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}${wsPath}${queryString}`;
} catch (error) {
return `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${wsPath}${queryString}`;
}
}
get reconnectAttempts() {
return this.options?.reconnectAttempts || 30;
}
@ -90,7 +98,7 @@ export class WebSocketClient {
return;
}
this._reconnectTimes++;
const ws = new WebSocket(this.options.url || getWebSocketURL(), this.options.protocols);
const ws = new WebSocket(this.getURL(), this.options.protocols);
let pingIntervalTimer: any;
ws.onopen = () => {
console.log('[nocobase-ws]: connected.');

View File

@ -10,6 +10,7 @@ import { useAPIClient } from '../api-client';
import { Application } from '../application';
import { Plugin } from '../application/Plugin';
import { BlockSchemaComponentPlugin } from '../block-provider';
import { CollectionPlugin } from '../collection-manager';
import { RemoteDocumentTitlePlugin } from '../document-title';
import { PinnedListPlugin } from '../plugin-manager';
import { PMPlugin } from '../pm';
@ -22,7 +23,6 @@ import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
import { SystemSettingsPlugin } from '../system-settings';
import { CurrentUserProvider, CurrentUserSettingsMenuProvider } from '../user';
import { LocalePlugin } from './plugins/LocalePlugin';
import { CollectionPlugin } from '../collection-manager';
const AppSpin = () => {
return (
@ -36,7 +36,7 @@ const useErrorProps = (app: Application, error: any) => {
return {};
}
const err = error?.response?.data?.errors?.[0] || error;
const subApp = getSubAppName();
const subApp = getSubAppName(app.getPublicPath());
switch (err.code) {
case 'USER_HAS_NO_ROLES_ERR':
return {

View File

@ -37,6 +37,7 @@ function getUmiConfig() {
return memo;
}, {}),
define: {
'process.env.APP_PUBLIC_PATH': process.env.APP_PUBLIC_PATH,
'process.env.WS_PATH': process.env.WS_PATH,
'process.env.API_BASE_URL': API_BASE_URL || API_BASE_PATH,
'process.env.APP_ENV': process.env.APP_ENV,

View File

@ -48,7 +48,7 @@ export class Auth {
if (typeof window === 'undefined') {
return;
}
const appName = getSubAppName();
const appName = getSubAppName(this.api['app'] ? this.api['app'].getPublicPath() : '/');
if (!appName) {
return;
}

View File

@ -1,9 +1,11 @@
const getSubAppName = () => {
const match = window.location.pathname.match(/^\/apps\/([^/]*)\/?/);
if (!match) {
return '';
const getSubAppName = (publicPath = '/') => {
const prefix = `${publicPath}apps/`;
if (!window.location.pathname.startsWith(prefix)) {
return;
}
return match[1];
const pathname = window.location.pathname.substring(prefix.length);
const args = pathname.split('/', 1);
return args[0] || '';
};
export default getSubAppName;

View File

@ -1,3 +1,3 @@
export * from './APIClient';
export { default as getSubAppName } from './getSubAppName';
export { default as getSubAppName } from './getSubAppName';

View File

@ -15,7 +15,7 @@ import handler from 'serve-handler';
import { parse } from 'url';
import { AppSupervisor } from '../app-supervisor';
import { ApplicationOptions } from '../application';
import { PLUGIN_STATICS_PATH, getPackageDirByExposeUrl, getPackageNameByExposeUrl } from '../plugin-manager';
import { getPackageDirByExposeUrl, getPackageNameByExposeUrl } from '../plugin-manager';
import { applyErrorWithArgs, getErrorWithCode } from './errors';
import { IPCSocketClient } from './ipc-socket-client';
import { IPCSocketServer } from './ipc-socket-server';
@ -168,14 +168,16 @@ export class Gateway extends EventEmitter {
async requestHandler(req: IncomingMessage, res: ServerResponse) {
const { pathname } = parse(req.url);
const { PLUGIN_STATICS_PATH, APP_PUBLIC_PATH } = process.env;
if (pathname === '/__umi/api/bundle-status') {
if (pathname.endsWith('/__umi/api/bundle-status')) {
res.statusCode = 200;
res.end('ok');
return;
}
if (pathname.startsWith('/storage/uploads/')) {
if (pathname.startsWith(APP_PUBLIC_PATH + 'storage/uploads/')) {
req.url = req.url.substring(APP_PUBLIC_PATH.length - 1);
await compress(req, res);
return handler(req, res, {
public: resolve(process.cwd()),
@ -203,7 +205,8 @@ export class Gateway extends EventEmitter {
});
}
if (!pathname.startsWith('/api')) {
if (!pathname.startsWith(process.env.API_BASE_PATH)) {
req.url = req.url.substring(APP_PUBLIC_PATH.length - 1);
await compress(req, res);
return handler(req, res, {
public: `${process.env.APP_PACKAGE_ROOT}/dist/client`,

View File

@ -36,7 +36,7 @@ export function getPackageFilePathWithExistCheck(packageName: string, filePath:
}
export function getExposeUrl(packageName: string, filePath: string) {
return `${PLUGIN_STATICS_PATH}${packageName}/${filePath}`;
return `${process.env.PLUGIN_STATICS_PATH}${packageName}/${filePath}`;
}
export function getExposeReadmeUrl(packageName: string, lang: string) {
@ -63,7 +63,7 @@ export function getExposeChangelogUrl(packageName: string) {
* getPluginNameByClientStaticUrl('/static/plugins/@nocobase/foo/README.md') => '@nocobase/foo'
*/
export function getPackageNameByExposeUrl(pathname: string) {
pathname = pathname.replace(PLUGIN_STATICS_PATH, '');
pathname = pathname.replace(process.env.PLUGIN_STATICS_PATH, '');
const pathArr = pathname.split('/');
if (pathname.startsWith('@')) {
return pathArr.slice(0, 2).join('/');

View File

@ -5,7 +5,6 @@ import Application from '../../application';
import { getExposeUrl } from '../clientStaticUtils';
import PluginManager from '../plugin-manager';
//@ts-ignore
import { version } from '../../../package.json';
export default {
name: 'pm',
@ -131,7 +130,10 @@ export default {
try {
return {
...item.toJSON(),
url: `${getExposeUrl(item.packageName, PLUGIN_CLIENT_ENTRY_FILE)}?version=${item.version}`,
url: `${process.env.APP_SERVER_BASE_URL}${getExposeUrl(
item.packageName,
PLUGIN_CLIENT_ENTRY_FILE,
)}?version=${item.version}`,
};
} catch {
return false;

View File

@ -1,4 +1,5 @@
import { css, useAPIClient, useRequest } from '@nocobase/client';
import { css, useAPIClient, useApp, useRequest } from '@nocobase/client';
import { getSubAppName } from '@nocobase/sdk';
import { Select, Space, Spin, Typography } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import SwaggerUIBundle from 'swagger-ui-dist/swagger-ui-bundle';
@ -13,11 +14,12 @@ const Documentation = () => {
const { t } = useTranslation();
const swaggerUIRef = useRef();
const { data: urls } = useRequest<{ data: { name: string; url: string }[] }>({ url: 'swagger:getUrls' });
const app = useApp();
const requestInterceptor = (req) => {
if (!req.headers['Authorization']) {
const match = location.pathname.match(/^\/apps\/([^/]*)\//);
if (match?.[1]) {
req.headers['X-App'] = match?.[1];
const appName = getSubAppName(app.getPublicPath());
if (appName) {
req.headers['X-App'] = appName;
}
req.headers['Authorization'] = `Bearer ${apiClient.auth.getToken()}`;
}

View File

@ -1,10 +1,10 @@
import APIDocPlugin from '../server';
import baseSwagger from './base-swagger';
import collectionToSwaggerObject from './collections';
import { SchemaTypeMapping } from './constants';
import { createDefaultActionSwagger, getInterfaceCollection } from './helpers';
import { getPluginsSwagger, getSwaggerDocument, loadSwagger } from './loader';
import { merge } from './merge';
import collectionToSwaggerObject from './collections';
export class SwaggerManager {
private plugin: APIDocPlugin;
@ -81,6 +81,10 @@ export class SwaggerManager {
return merge(await this.getBaseSwagger(), await loadSwagger('@nocobase/server'));
}
getURL(pathname: string) {
return process.env.API_BASE_PATH + pathname;
}
async getUrls() {
const plugins = await getPluginsSwagger(this.db)
.then((res) => {
@ -88,7 +92,7 @@ export class SwaggerManager {
const schema = res[name];
return {
name: schema.info?.title || name,
url: `/api/swagger:get?ns=${encodeURIComponent(`plugins/${name}`)}`,
url: this.getURL(`swagger:get?ns=${encodeURIComponent(`plugins/${name}`)}`),
};
});
})
@ -102,25 +106,25 @@ export class SwaggerManager {
return [
{
name: 'NocoBase API',
url: '/api/swagger:get',
url: this.getURL('swagger:get'),
},
{
name: 'NocoBase API - Core',
url: '/api/swagger:get?ns=core',
url: this.getURL('swagger:get?ns=core'),
},
{
name: 'NocoBase API - All plugins',
url: '/api/swagger:get?ns=plugins',
url: this.getURL('swagger:get?ns=plugins'),
},
{
name: 'NocoBase API - Custom collections',
url: '/api/swagger:get?ns=collections',
url: this.getURL('swagger:get?ns=collections'),
},
...plugins,
...collections.map((collection) => {
return {
name: `Collection API - ${collection.title}`,
url: `/api/swagger:get?ns=${encodeURIComponent('collections/' + collection.name)}`,
url: this.getURL(`swagger:get?ns=${encodeURIComponent('collections/' + collection.name)}`),
};
}),
];

View File

@ -1,19 +1,23 @@
import React, { useEffect } from 'react';
import { LoginOutlined } from '@ant-design/icons';
import { Button, Space, message } from 'antd';
import { useLocation } from 'react-router-dom';
import { getSubAppName } from '@nocobase/sdk';
import { useApp } from '@nocobase/client';
import { Authenticator } from '@nocobase/plugin-auth/client';
import { getSubAppName } from '@nocobase/sdk';
import { Button, Space, message } from 'antd';
import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export const SigninPage = (props: { authenticator: Authenticator }) => {
const authenticator = props.authenticator;
const location = useLocation();
const params = new URLSearchParams(location.search);
const redirect = params.get('redirect');
const app = useApp();
const app = getSubAppName() || 'main';
const appName = getSubAppName(app.getPublicPath()) || 'main';
const login = async () => {
window.location.replace(`/api/cas:login?authenticator=${authenticator.name}&__appName=${app}&redirect=${redirect}`);
window.location.replace(
`/api/cas:login?authenticator=${authenticator.name}&__appName=${appName}&redirect=${redirect}`,
);
};
useEffect(() => {

View File

@ -9,7 +9,7 @@ export const service = async (ctx: Context, next: Next) => {
if (appName && appName !== 'main') {
const appSupervisor = AppSupervisor.getInstance();
if (appSupervisor?.runningMode !== 'single') {
prefix = `/apps/${appName}`;
prefix = process.env.APP_PUBLIC_PATH + `apps/${appName}`;
}
}

View File

@ -19,7 +19,7 @@ export class CASAuth extends BaseAuth {
const opts = this.options || {};
return {
...opts,
serviceUrl: `${opts.serviceDomain}/api/cas:service`,
serviceUrl: `${opts.serviceDomain}${process.env.API_BASE_PATH}cas:service`,
};
}

View File

@ -10,6 +10,9 @@ export class FileModel extends Model {
if (!json['thumbnailRule'] && storage?.type === 'ali-oss') {
json['thumbnailRule'] = '?x-oss-process=image/auto-orient,1/resize,m_fill,w_94,h_94/quality,q_90';
}
if (storage?.type === 'local' && process.env.APP_PUBLIC_PATH) {
json['url'] = process.env.APP_PUBLIC_PATH.replace(/\/$/g, '') + json.url;
}
}
return json;
}

View File

@ -1,5 +1,5 @@
import { observer, useField } from '@formily/react';
import { useAPIClient } from '@nocobase/client';
import { useAPIClient, useApp } from '@nocobase/client';
import { Card } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -22,13 +22,16 @@ export const Iframe: any = observer(
const { t } = useTranslation();
const api = useAPIClient();
const field = useField();
const app = useApp();
const src = React.useMemo(() => {
if (mode === 'html') {
return `/api/iframeHtml:getHtml/${htmlId}?token=${api.auth.getToken()}&v=${field.data?.v || ''}`;
const options = app.getOptions();
const apiBaseURL: string = options?.apiClient?.['baseURL'];
return `${apiBaseURL}iframeHtml:getHtml/${htmlId}?token=${api.auth.getToken()}&v=${field.data?.v || ''}`;
}
return url;
}, [url, mode, htmlId, field.data?.v]);
}, [app, url, mode, htmlId, field.data?.v]);
if ((mode === 'url' && !url) || (mode === 'html' && !htmlId)) {
return <Card style={{ marginBottom: 24 }}>{t('Please fill in the iframe URL')}</Card>;

View File

@ -1,18 +1,14 @@
import { useApp } from '@nocobase/client';
import { Card, Form, Input } from 'antd';
import React, { useMemo } from 'react';
import { useTranslation } from '../locale';
import { useLocation } from 'react-router-dom';
export const AppConfiguration = () => {
const app = useApp();
const { t } = useTranslation();
const location = useLocation();
const targetUrl = useMemo(() => {
let baseUrl = '/mobile';
if (location.pathname.startsWith('/apps')) {
baseUrl = location.pathname.split('/').slice(0, 3).join('/');
}
return baseUrl;
}, [location.pathname]);
return app.getRouteUrl('/mobile');
}, [app]);
return (
<Card
style={{

View File

@ -1,20 +1,15 @@
import { LinkOutlined } from '@ant-design/icons';
import { css, useApp } from '@nocobase/client';
import { Button } from 'antd';
import React from 'react';
import { useTranslation } from '../locale';
import { useLocation, useNavigate } from 'react-router-dom';
import { css } from '@nocobase/client';
import { Button } from 'antd';
import { LinkOutlined } from '@ant-design/icons';
export const OpenInNewTab = () => {
const location = useLocation();
const { t } = useTranslation();
const app = useApp();
const onOpenInNewTab = () => {
let baseUrl = window.origin;
if (window.location.pathname.startsWith('/apps')) {
baseUrl = window.origin + window.location.pathname.split('/').slice(0, 3).join('/');
}
window.open(`${baseUrl}${location.pathname}${location.search}`);
window.open(app.getRouteUrl('/mobile'));
};
return (

View File

@ -1,4 +1,4 @@
import { SchemaComponent, useRecord } from '@nocobase/client';
import { SchemaComponent, useApp, useRecord } from '@nocobase/client';
import { Card } from 'antd';
import React from 'react';
import { schema } from './settings/schemas/applications';
@ -6,10 +6,11 @@ import { usePluginUtils } from './utils';
const useLink = () => {
const record = useRecord();
const app = useApp();
if (record.options?.standaloneDeployment && record.cname) {
return `//${record.cname}`;
}
return `/apps/${record.name}/admin/`;
return app.getRouteUrl(`/apps/${record.name}/admin/`);
};
const AppVisitor = () => {

View File

@ -1,10 +1,12 @@
import { connect, mapReadPretty } from '@formily/react';
import { useApp } from '@nocobase/client';
import { Input as AntdInput } from 'antd';
import React from 'react';
const ReadPretty = (props) => {
const app = useApp();
const content = props.value && (
<a target={'_blank'} href={`/apps/${props.value}/admin`} rel="noreferrer">
<a target={'_blank'} href={app.getRouteUrl(`/apps/${props.value}/admin`)} rel="noreferrer">
{props.value}
</a>
);

View File

@ -20,10 +20,10 @@ const MultiAppManager = () => {
},
);
const { t } = usePluginUtils();
const app = useApp();
const instance = useApp();
const items = [
...(data?.data || []).map((app) => {
let link = `/apps/${app.name}/admin/`;
let link = instance.getRouteUrl(`/apps/${app.name}/admin/`);
if (app.options?.standaloneDeployment && app.cname) {
link = `//${app.cname}`;
}
@ -38,7 +38,9 @@ const MultiAppManager = () => {
}),
{
key: '.manager',
label: <Link to={app.pluginSettingsManager.getRoutePath('multi-app-manager')}>{t('Manage applications')}</Link>,
label: (
<Link to={instance.pluginSettingsManager.getRoutePath('multi-app-manager')}>{t('Manage applications')}</Link>
),
},
];
return (

View File

@ -115,7 +115,7 @@ const defaultAppOptionsFactory = (appName: string, mainApp: Application) => {
},
plugins: ['nocobase'],
resourcer: {
prefix: '/api',
prefix: process.env.API_BASE_PATH,
},
};
};

View File

@ -274,7 +274,7 @@ export class MultiAppShareCollectionPlugin extends Plugin {
}),
plugins: plugins.includes('nocobase') ? ['nocobase'] : plugins,
resourcer: {
prefix: '/api',
prefix: process.env.API_BASE_PATH,
},
logger: {
...mainApp.options.logger,

View File

@ -1,9 +1,9 @@
import { CopyOutlined } from '@ant-design/icons';
import { ArrayItems, FormTab } from '@formily/antd-v5';
import { observer } from '@formily/react';
import { FormItem, Input, SchemaComponent } from '@nocobase/client';
import { FormItem, Input, SchemaComponent, useApp } from '@nocobase/client';
import { Card, Space, message } from 'antd';
import React from 'react';
import React, { useMemo } from 'react';
import { lang, useOidcTranslation } from './locale';
const schema = {
@ -327,9 +327,16 @@ const schema = {
const Usage = observer(
() => {
const { t } = useOidcTranslation();
const app = useApp();
const { protocol, host } = window.location;
const url = `${protocol}//${host}/api/oidc:redirect`;
const url = useMemo(() => {
const options = app.getOptions();
const apiBaseURL: string = options?.apiClient?.['baseURL'];
const { protocol, host } = window.location;
return apiBaseURL.startsWith('http')
? `${apiBaseURL}oidc:redirect`
: `${protocol}//${host}${apiBaseURL}oidc:redirect`;
}, [app]);
const copy = (text: string) => {
navigator.clipboard.writeText(text);

View File

@ -1,6 +1,6 @@
import { Context, Next } from '@nocobase/actions';
import { OIDCAuth } from '../oidc-auth';
import { AppSupervisor } from '@nocobase/server';
import { OIDCAuth } from '../oidc-auth';
export const redirect = async (ctx: Context, next: Next) => {
const {
@ -14,7 +14,7 @@ export const redirect = async (ctx: Context, next: Next) => {
if (appName && appName !== 'main') {
const appSupervisor = AppSupervisor.getInstance();
if (appSupervisor?.runningMode !== 'single') {
prefix = `/apps/${appName}`;
prefix = process.env.APP_PUBLIC_PATH + `apps/${appName}`;
}
}
const auth = (await ctx.app.authManager.get(authenticator, ctx)) as OIDCAuth;

View File

@ -1,7 +1,7 @@
import { AuthConfig, BaseAuth } from '@nocobase/auth';
import { AuthModel } from '@nocobase/plugin-auth';
import { Issuer } from 'openid-client';
import { cookieName } from '../constants';
import { AuthModel } from '@nocobase/plugin-auth';
export class OIDCAuth extends BaseAuth {
constructor(config: AuthConfig) {
@ -17,7 +17,7 @@ export class OIDCAuth extends BaseAuth {
const { http, port } = this.getOptions();
const protocol = http ? 'http' : 'https';
const host = port ? `${ctx.hostname}${port ? `:${port}` : ''}` : ctx.host;
return `${protocol}://${host}/api/oidc:redirect`;
return `${protocol}://${host}${process.env.API_BASE_PATH}oidc:redirect`;
}
getOptions() {

View File

@ -1,11 +1,10 @@
import React from 'react';
import { SchemaComponent } from '@nocobase/client';
import { Card, message } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import { observer, useForm } from '@formily/react';
import { useRecord, FormItem, Input } from '@nocobase/client';
import { lang, useSamlTranslation } from './locale';
import { FormItem, Input, SchemaComponent, useApp, useRecord } from '@nocobase/client';
import { getSubAppName } from '@nocobase/sdk';
import { Card, message } from 'antd';
import React, { useMemo } from 'react';
import { lang, useSamlTranslation } from './locale';
const schema = {
type: 'object',
@ -74,10 +73,19 @@ const Usage = observer(
const record = useRecord();
const { t } = useSamlTranslation();
const app = getSubAppName() || 'main';
const app = useApp();
const name = form.values.name ?? record.name;
const { protocol, host } = window.location;
const url = `${protocol}//${host}/api/saml:redirect?authenticator=${name}&__appName=${app}`;
const url = useMemo(() => {
const options = app.getOptions();
const apiBaseURL: string = options?.apiClient?.['baseURL'];
const { protocol, host } = window.location;
const appName = getSubAppName(app.getPublicPath()) || 'main';
return apiBaseURL.startsWith('http')
? `${apiBaseURL}saml:redirect?authenticator=${name}&__appName=${appName}`
: `${protocol}//${host}${apiBaseURL}saml:redirect?authenticator=${name}&__appName=${appName}`;
}, [app, name]);
const copy = (text: string) => {
navigator.clipboard.writeText(text);

View File

@ -1,6 +1,6 @@
import { Context, Next } from '@nocobase/actions';
import { SAMLAuth } from '../saml-auth';
import { AppSupervisor } from '@nocobase/server';
import { SAMLAuth } from '../saml-auth';
export const redirect = async (ctx: Context, next: Next) => {
const { authenticator, __appName: appName } = ctx.action.params || {};
@ -9,7 +9,7 @@ export const redirect = async (ctx: Context, next: Next) => {
if (appName && appName !== 'main') {
const appSupervisor = AppSupervisor.getInstance();
if (appSupervisor?.runningMode !== 'single') {
prefix = `/apps/${appName}`;
prefix = process.env.APP_PUBLIC_PATH + `apps/${appName}`;
}
}
const auth = (await ctx.app.authManager.get(authenticator, ctx)) as SAMLAuth;

View File

@ -24,7 +24,7 @@ export class SAMLAuth extends BaseAuth {
const name = this.authenticator.get('name');
const protocol = http ? 'http' : 'https';
return {
callbackUrl: `${protocol}://${ctx.host}/api/saml:redirect?authenticator=${name}&__appName=${ctx.app.name}`,
callbackUrl: `${protocol}://${ctx.host}${process.env.API_BASE_PATH}saml:redirect?authenticator=${name}&__appName=${ctx.app.name}`,
entryPoint: ssoUrl,
issuer: name,
cert: certificate,

View File

@ -1,4 +1,4 @@
import { NocoBaseBuildInPlugin, Plugin } from '@nocobase/client';
import { Application, NocoBaseBuildInPlugin, Plugin } from '@nocobase/client';
const getCurrentTimezone = () => {
const timezoneOffset = new Date().getTimezoneOffset() / -60;
@ -6,15 +6,17 @@ const getCurrentTimezone = () => {
return (timezoneOffset > 0 ? '+' : '-') + timezone;
};
function getBasename() {
const match = location.pathname.match(/^\/apps\/([^/]*)\//);
return match ? match[0] : '/';
function getBasename(app: Application) {
const publicPath = app.getPublicPath();
const pattern = `^${publicPath}apps/([^/]*)/`;
const match = location.pathname.match(new RegExp(pattern));
return match ? match[0] : publicPath;
}
export class NocoBaseClientPresetPlugin extends Plugin {
async afterAdd() {
this.router.setType('browser');
this.router.setBasename(getBasename());
this.router.setBasename(getBasename(this.app));
this.app.apiClient.axios.interceptors.request.use((config) => {
config.headers['X-Hostname'] = window?.location?.hostname;
config.headers['X-Timezone'] = getCurrentTimezone();

3
storage/.gitignore vendored
View File

@ -1,4 +1,5 @@
.pm2
tmp
app.watch.ts
/e2e
/e2e
nocobase.conf