feat: Support running under subpath (#1451)

This commit is contained in:
Roberto Fernández Iglesias
2026-03-12 22:11:11 +01:00
committed by GitHub
parent 61a3ff55cc
commit 5e6195b252
24 changed files with 97 additions and 33 deletions

View File

@@ -11,7 +11,7 @@ server/test
server/.tmp server/.tmp
server/.venv server/.venv
server/views/index.html server/views/index.ejs
server/data/* server/data/*
!server/data/.gitkeep !server/data/.gitkeep

View File

@@ -34,7 +34,7 @@ jobs:
working-directory: ./client working-directory: ./client
- name: Build client - name: Build client
run: DISABLE_ESLINT_PLUGIN=true npm run build run: INDEX_FORMAT=ejs DISABLE_ESLINT_PLUGIN=true npm run build
working-directory: ./client working-directory: ./client
- name: Include licenses into dist - name: Include licenses into dist
@@ -45,7 +45,7 @@ jobs:
- name: Include built client into dist - name: Include built client into dist
run: | run: |
mv ../../client/dist/* public mv ../../client/dist/* public
cp public/index.html views mv public/index.ejs views
working-directory: ./server/dist working-directory: ./server/dist
- name: Create release package - name: Create release package

View File

@@ -24,13 +24,13 @@ jobs:
- name: Build client - name: Build client
run: | run: |
DISABLE_ESLINT_PLUGIN=true npm run build INDEX_FORMAT=ejs DISABLE_ESLINT_PLUGIN=true npm run build
mv dist build mv dist build
working-directory: ./client working-directory: ./client
- name: Update Dockerfile to use prebuilt client - name: Update Dockerfile to use prebuilt client
run: | run: |
sed -i '/^FROM node:22 AS client/,/^ && DISABLE_ESLINT_PLUGIN=true npm run build$/c\ sed -i '/^FROM node:22 AS client/,/^ && INDEX_FORMAT=ejs DISABLE_ESLINT_PLUGIN=true npm run build$/c\
FROM node:22 AS client\n\ FROM node:22 AS client\n\
WORKDIR /app\n\ WORKDIR /app\n\
COPY client/build /app/dist' Dockerfile COPY client/build /app/dist' Dockerfile

View File

@@ -38,13 +38,13 @@ jobs:
- name: Build client - name: Build client
run: | run: |
DISABLE_ESLINT_PLUGIN=true npm run build INDEX_FORMAT=ejs DISABLE_ESLINT_PLUGIN=true npm run build
mv dist build mv dist build
working-directory: ./client working-directory: ./client
- name: Update Dockerfile to use prebuilt client - name: Update Dockerfile to use prebuilt client
run: | run: |
sed -i '/^FROM node:22 AS client/,/^ && DISABLE_ESLINT_PLUGIN=true npm run build$/c\ sed -i '/^FROM node:22 AS client/,/^ && INDEX_FORMAT=ejs DISABLE_ESLINT_PLUGIN=true npm run build$/c\
FROM node:22 AS client\n\ FROM node:22 AS client\n\
WORKDIR /app\n\ WORKDIR /app\n\
COPY client/build /app/dist' Dockerfile COPY client/build /app/dist' Dockerfile

View File

@@ -46,7 +46,7 @@ jobs:
run: | run: |
npm install npm install
cd client cd client
npm run build INDEX_FORMAT=ejs npm run build
- name: Set up and start server for testing - name: Set up and start server for testing
env: env:

View File

@@ -22,7 +22,7 @@ COPY client .
RUN npm install npm --global \ RUN npm install npm --global \
&& npm install --omit=dev \ && npm install --omit=dev \
&& DISABLE_ESLINT_PLUGIN=true npm run build && INDEX_FORMAT=ejs DISABLE_ESLINT_PLUGIN=true npm run build
# Stage 3: Final image # Stage 3: Final image
FROM node:22-alpine FROM node:22-alpine
@@ -41,12 +41,12 @@ COPY --from=server --chown=node:node /app/node_modules node_modules
COPY --from=server --chown=node:node /app/dist . COPY --from=server --chown=node:node /app/dist .
COPY --from=client --chown=node:node /app/dist public COPY --from=client --chown=node:node /app/dist public
COPY --from=client --chown=node:node /app/dist/index.html views
RUN python3 -m venv .venv \ RUN python3 -m venv .venv \
&& .venv/bin/pip3 install --upgrade pip \ && .venv/bin/pip3 install --upgrade pip \
&& .venv/bin/pip3 install -r requirements.txt --no-cache-dir \ && .venv/bin/pip3 install -r requirements.txt --no-cache-dir \
&& mv .env.sample .env \ && mv .env.sample .env \
&& mv public/index.ejs views \
&& npm config set update-notifier false && npm config set update-notifier false
VOLUME /app/data VOLUME /app/data

View File

@@ -18,7 +18,7 @@ const http = {};
return result; return result;
}, new FormData()); }, new FormData());
return fetch(`${Config.SERVER_BASE_URL}/api${url}`, { return fetch(`${Config.BASE_PATH}/api${url}`, {
method, method,
headers, headers,
body: formData, body: formData,

View File

@@ -10,7 +10,7 @@ import Config from '../constants/Config';
const io = sailsIOClient(socketIOClient); const io = sailsIOClient(socketIOClient);
io.sails.url = Config.SERVER_BASE_URL; io.sails.path = `${Config.BASE_PATH}/socket.io`;
io.sails.autoConnect = false; io.sails.autoConnect = false;
io.sails.reconnection = true; io.sails.reconnection = true;
io.sails.useCORSRouteToGetCookie = false; io.sails.useCORSRouteToGetCookie = false;

View File

@@ -10,6 +10,7 @@ import { useSelector } from 'react-redux';
import history from '../../../history'; import history from '../../../history';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import matchPaths from '../../../utils/match-paths'; import matchPaths from '../../../utils/match-paths';
import Config from '../../../constants/Config';
import Paths from '../../../constants/Paths'; import Paths from '../../../constants/Paths';
const Link = React.memo(({ href, content, stopPropagation, ...props }) => { const Link = React.memo(({ href, content, stopPropagation, ...props }) => {
@@ -23,7 +24,8 @@ const Link = React.memo(({ href, content, stopPropagation, ...props }) => {
} }
}, [href]); }, [href]);
const isSameSite = !!url && url.origin === window.location.origin; const isSameSite =
!!url && url.origin === window.location.origin && url.pathname.startsWith(Config.BASE_PATH);
const cardsPathMatch = useMemo(() => { const cardsPathMatch = useMemo(() => {
if (!isSameSite) { if (!isSameSite) {

View File

@@ -4,6 +4,7 @@
*/ */
import history from '../../history'; import history from '../../history';
import Config from '../../constants/Config';
const SAME_SITE_CLASS = 'same-site'; const SAME_SITE_CLASS = 'same-site';
@@ -30,7 +31,9 @@ function process(token, nextToken) {
return; return;
} }
const isSameSite = url.origin === window.location.origin; const isSameSite =
url.origin === window.location.origin && url.pathname.startsWith(Config.BASE_PATH);
const trimOrigin = isSameSite && nextToken.type === 'text' && nextToken.content === href; const trimOrigin = isSameSite && nextToken.type === 'text' && nextToken.content === href;
if (isSameSite) { if (isSameSite) {

View File

@@ -3,8 +3,7 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
const SERVER_BASE_URL = const BASE_PATH = window.BASE_PATH || '';
import.meta.env.VITE_SERVER_BASE_URL || (import.meta.env.DEV ? 'http://localhost:1337' : '');
const ACCESS_TOKEN_KEY = 'accessToken'; const ACCESS_TOKEN_KEY = 'accessToken';
const ACCESS_TOKEN_VERSION_KEY = 'accessTokenVersion'; const ACCESS_TOKEN_VERSION_KEY = 'accessTokenVersion';
@@ -20,7 +19,7 @@ const MAX_SIZE_TO_DISPLAY_CONTENT = 256 * 1024;
const IS_MAC = navigator.platform.startsWith('Mac'); const IS_MAC = navigator.platform.startsWith('Mac');
export default { export default {
SERVER_BASE_URL, BASE_PATH,
ACCESS_TOKEN_KEY, ACCESS_TOKEN_KEY,
ACCESS_TOKEN_VERSION_KEY, ACCESS_TOKEN_VERSION_KEY,
ACCESS_TOKEN_VERSION, ACCESS_TOKEN_VERSION,

View File

@@ -3,12 +3,14 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
const ROOT = '/'; import Config from './Config';
const LOGIN = '/login';
const OIDC_CALLBACK = '/oidc-callback'; const ROOT = `${Config.BASE_PATH}/`;
const PROJECTS = '/projects/:id'; const LOGIN = `${Config.BASE_PATH}/login`;
const BOARDS = '/boards/:id'; const OIDC_CALLBACK = `${Config.BASE_PATH}/oidc-callback`;
const CARDS = '/cards/:id'; const PROJECTS = `${Config.BASE_PATH}/projects/:id`;
const BOARDS = `${Config.BASE_PATH}/boards/:id`;
const CARDS = `${Config.BASE_PATH}/cards/:id`;
export default { export default {
ROOT, ROOT,

View File

@@ -8,24 +8,28 @@ import { jwtDecode } from 'jwt-decode';
import Config from '../constants/Config'; import Config from '../constants/Config';
const PATH = Config.BASE_PATH || '/';
export const setAccessToken = (accessToken) => { export const setAccessToken = (accessToken) => {
const { exp } = jwtDecode(accessToken); const { exp } = jwtDecode(accessToken);
const expires = new Date(exp * 1000); const expires = new Date(exp * 1000);
Cookies.set(Config.ACCESS_TOKEN_KEY, accessToken, { Cookies.set(Config.ACCESS_TOKEN_KEY, accessToken, {
expires, expires,
path: PATH,
secure: window.location.protocol === 'https:', secure: window.location.protocol === 'https:',
sameSite: 'strict', sameSite: 'strict',
}); });
Cookies.set(Config.ACCESS_TOKEN_VERSION_KEY, Config.ACCESS_TOKEN_VERSION, { Cookies.set(Config.ACCESS_TOKEN_VERSION_KEY, Config.ACCESS_TOKEN_VERSION, {
expires, expires,
path: PATH,
}); });
}; };
export const removeAccessToken = () => { export const removeAccessToken = () => {
Cookies.remove(Config.ACCESS_TOKEN_KEY); Cookies.remove(Config.ACCESS_TOKEN_KEY, { path: PATH });
Cookies.remove(Config.ACCESS_TOKEN_VERSION_KEY); Cookies.remove(Config.ACCESS_TOKEN_VERSION_KEY, { path: PATH });
}; };
export const getAccessToken = () => { export const getAccessToken = () => {

View File

@@ -17,6 +17,6 @@ ln -s ${CLIENT_PATH}/logo512.png ${SERVER_PUBLIC_PATH}/logo512.png && echo "Link
ln -s ${CLIENT_PATH}/manifest.json ${SERVER_PUBLIC_PATH}/manifest.json && echo "Linked manifest.json successfully" ln -s ${CLIENT_PATH}/manifest.json ${SERVER_PUBLIC_PATH}/manifest.json && echo "Linked manifest.json successfully"
ln -s ${CLIENT_PATH}/robots.txt ${SERVER_PUBLIC_PATH}/robots.txt && echo "Linked robots.txt successfully" ln -s ${CLIENT_PATH}/robots.txt ${SERVER_PUBLIC_PATH}/robots.txt && echo "Linked robots.txt successfully"
ln -s ${CLIENT_PATH}/assets ${SERVER_PUBLIC_PATH}/assets && echo "Linked assets folder successfully" ln -s ${CLIENT_PATH}/assets ${SERVER_PUBLIC_PATH}/assets && echo "Linked assets folder successfully"
ln -s ${CLIENT_PATH}/index.html ${SERVER_VIEWS_PATH}/index.html && echo "Linked index.html successfully" ln -s ${CLIENT_PATH}/index.ejs ${SERVER_VIEWS_PATH}/index.ejs && echo "Linked index.ejs successfully"
echo "Setup symbolic links completed successfully." echo "Setup symbolic links completed successfully."

View File

@@ -1,13 +1,42 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import commonjs from 'vite-plugin-commonjs'; import commonjs from 'vite-plugin-commonjs';
import { nodePolyfills } from 'vite-plugin-node-polyfills'; import { nodePolyfills } from 'vite-plugin-node-polyfills';
// eslint-disable-next-line import/no-unresolved
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr'; import svgr from 'vite-plugin-svgr';
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import browserslistToEsbuild from 'browserslist-to-esbuild'; import browserslistToEsbuild from 'browserslist-to-esbuild';
// eslint-disable-next-line no-underscore-dangle
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const createEjsTemplate = () => ({
name: 'create-ejs-template',
closeBundle() {
if (process.env.INDEX_FORMAT !== 'ejs') return;
const distPath = path.resolve(__dirname, 'dist');
const htmlPath = path.join(distPath, 'index.html');
if (!fs.existsSync(htmlPath)) return;
const html = fs.readFileSync(htmlPath, 'utf8');
const ejs = html
.replace(/(href|src)="\.\/([^"]+)"/g, '$1="<%- basePath %>/$2"')
.replace('</head>', " <script>window.BASE_PATH = '<%- basePath %>';</script>\n </head>");
fs.writeFileSync(path.join(distPath, 'index.ejs'), ejs);
fs.unlinkSync(htmlPath);
},
});
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: './',
plugins: [ plugins: [
commonjs(), commonjs(),
nodePolyfills({ nodePolyfills({
@@ -15,6 +44,7 @@ export default defineConfig({
}), }),
react(), react(),
svgr(), svgr(),
createEjsTemplate(),
], ],
resolve: { resolve: {
alias: { alias: {
@@ -24,6 +54,10 @@ export default defineConfig({
server: { server: {
port: 3000, port: 3000,
open: true, open: true,
proxy: {
'/api': 'http://localhost:1337',
'/socket.io': { target: 'http://localhost:1337', ws: true },
},
}, },
build: { build: {
target: browserslistToEsbuild(['>0.2%', 'not dead', 'not op_mini all']), target: browserslistToEsbuild(['>0.2%', 'not dead', 'not op_mini all']),

View File

@@ -20,7 +20,7 @@ test
.tmp .tmp
.venv .venv
views/index.html views/index.ejs
data/* data/*

2
server/.gitignore vendored
View File

@@ -134,7 +134,7 @@ swagger.json
dist dist
logs logs
views/index.html views/index.ejs
data/* data/*
!data/.gitkeep !data/.gitkeep

View File

@@ -0,0 +1,19 @@
/*!
* Copyright (c) 2025 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
exits: {
success: {
responseType: 'view',
viewTemplatePath: 'index',
},
},
fn() {
return {
basePath: sails.config.custom.baseUrlPath,
};
},
};

View File

@@ -15,7 +15,7 @@ module.exports = {
fn(inputs) { fn(inputs) {
inputs.response.clearCookie('httpOnlyToken', { inputs.response.clearCookie('httpOnlyToken', {
path: sails.config.custom.baseUrlPath, path: sails.config.custom.baseUrlPath || '/',
}); });
}, },
}; };

View File

@@ -24,7 +24,7 @@ module.exports = {
fn(inputs) { fn(inputs) {
inputs.response.cookie('httpOnlyToken', inputs.value, { inputs.response.cookie('httpOnlyToken', inputs.value, {
expires: new Date(inputs.accessTokenPayload.exp * 1000), expires: new Date(inputs.accessTokenPayload.exp * 1000),
path: sails.config.custom.baseUrlPath, path: sails.config.custom.baseUrlPath || '/',
secure: sails.config.custom.baseUrlSecure, secure: sails.config.custom.baseUrlSecure,
httpOnly: true, httpOnly: true,
sameSite: 'strict', sameSite: 'strict',

View File

@@ -41,7 +41,7 @@ module.exports.custom = {
version, version,
baseUrl, baseUrl,
baseUrlPath: parsedBasedUrl.pathname, baseUrlPath: parsedBasedUrl.pathname.replace(/\/$/, ''), // Remove trailing slash
baseUrlSecure: parsedBasedUrl.protocol === 'https:', baseUrlSecure: parsedBasedUrl.protocol === 'https:',
maxUploadFileSize: envToBytes(process.env.MAX_UPLOAD_FILE_SIZE), maxUploadFileSize: envToBytes(process.env.MAX_UPLOAD_FILE_SIZE),

View File

@@ -44,6 +44,7 @@ module.exports.policies = {
'_internal/update-config': ['is-authenticated', 'is-internal'], '_internal/update-config': ['is-authenticated', 'is-internal'],
index: true,
'swagger/show': true, 'swagger/show': true,
'bootstrap/show': true, 'bootstrap/show': true,
'terms/show': true, 'terms/show': true,

View File

@@ -266,7 +266,7 @@ module.exports.routes = {
}, },
'GET /*': { 'GET /*': {
view: 'index', action: 'index',
skipAssets: true, skipAssets: true,
}, },
}; };

View File

@@ -24,7 +24,7 @@ module.exports.views = {
* *
*/ */
extension: 'html', // extension: 'ejs',
/** /**
* *