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/.venv
server/views/index.html
server/views/index.ejs
server/data/*
!server/data/.gitkeep

View File

@@ -34,7 +34,7 @@ jobs:
working-directory: ./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
- name: Include licenses into dist
@@ -45,7 +45,7 @@ jobs:
- name: Include built client into dist
run: |
mv ../../client/dist/* public
cp public/index.html views
mv public/index.ejs views
working-directory: ./server/dist
- name: Create release package

View File

@@ -24,13 +24,13 @@ jobs:
- name: Build client
run: |
DISABLE_ESLINT_PLUGIN=true npm run build
INDEX_FORMAT=ejs DISABLE_ESLINT_PLUGIN=true npm run build
mv dist build
working-directory: ./client
- name: Update Dockerfile to use prebuilt client
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\
WORKDIR /app\n\
COPY client/build /app/dist' Dockerfile

View File

@@ -38,13 +38,13 @@ jobs:
- name: Build client
run: |
DISABLE_ESLINT_PLUGIN=true npm run build
INDEX_FORMAT=ejs DISABLE_ESLINT_PLUGIN=true npm run build
mv dist build
working-directory: ./client
- name: Update Dockerfile to use prebuilt client
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\
WORKDIR /app\n\
COPY client/build /app/dist' Dockerfile

View File

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

View File

@@ -22,7 +22,7 @@ COPY client .
RUN npm install npm --global \
&& 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
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=client --chown=node:node /app/dist public
COPY --from=client --chown=node:node /app/dist/index.html views
RUN python3 -m venv .venv \
&& .venv/bin/pip3 install --upgrade pip \
&& .venv/bin/pip3 install -r requirements.txt --no-cache-dir \
&& mv .env.sample .env \
&& mv public/index.ejs views \
&& npm config set update-notifier false
VOLUME /app/data

View File

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

View File

@@ -10,7 +10,7 @@ import Config from '../constants/Config';
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.reconnection = true;
io.sails.useCORSRouteToGetCookie = false;

View File

@@ -10,6 +10,7 @@ import { useSelector } from 'react-redux';
import history from '../../../history';
import selectors from '../../../selectors';
import matchPaths from '../../../utils/match-paths';
import Config from '../../../constants/Config';
import Paths from '../../../constants/Paths';
const Link = React.memo(({ href, content, stopPropagation, ...props }) => {
@@ -23,7 +24,8 @@ const Link = React.memo(({ href, content, stopPropagation, ...props }) => {
}
}, [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(() => {
if (!isSameSite) {

View File

@@ -4,6 +4,7 @@
*/
import history from '../../history';
import Config from '../../constants/Config';
const SAME_SITE_CLASS = 'same-site';
@@ -30,7 +31,9 @@ function process(token, nextToken) {
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;
if (isSameSite) {

View File

@@ -3,8 +3,7 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const SERVER_BASE_URL =
import.meta.env.VITE_SERVER_BASE_URL || (import.meta.env.DEV ? 'http://localhost:1337' : '');
const BASE_PATH = window.BASE_PATH || '';
const ACCESS_TOKEN_KEY = 'accessToken';
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');
export default {
SERVER_BASE_URL,
BASE_PATH,
ACCESS_TOKEN_KEY,
ACCESS_TOKEN_VERSION_KEY,
ACCESS_TOKEN_VERSION,

View File

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

View File

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

View File

@@ -1,13 +1,42 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { defineConfig } from 'vite';
import commonjs from 'vite-plugin-commonjs';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
// eslint-disable-next-line import/no-unresolved
import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr';
// eslint-disable-next-line import/no-unresolved
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/
export default defineConfig({
base: './',
plugins: [
commonjs(),
nodePolyfills({
@@ -15,6 +44,7 @@ export default defineConfig({
}),
react(),
svgr(),
createEjsTemplate(),
],
resolve: {
alias: {
@@ -24,6 +54,10 @@ export default defineConfig({
server: {
port: 3000,
open: true,
proxy: {
'/api': 'http://localhost:1337',
'/socket.io': { target: 'http://localhost:1337', ws: true },
},
},
build: {
target: browserslistToEsbuild(['>0.2%', 'not dead', 'not op_mini all']),

View File

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

2
server/.gitignore vendored
View File

@@ -134,7 +134,7 @@ swagger.json
dist
logs
views/index.html
views/index.ejs
data/*
!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) {
inputs.response.clearCookie('httpOnlyToken', {
path: sails.config.custom.baseUrlPath,
path: sails.config.custom.baseUrlPath || '/',
});
},
};

View File

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

View File

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

View File

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

View File

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

View File

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