diff --git a/.dockerignore b/.dockerignore index 7f5bf79b..130e3269 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,7 +11,7 @@ server/test server/.tmp server/.venv -server/views/index.html +server/views/index.ejs server/data/* !server/data/.gitkeep diff --git a/.github/workflows/build-and-publish-release-package.yml b/.github/workflows/build-and-publish-release-package.yml index 3b9d8970..f53e7ac5 100644 --- a/.github/workflows/build-and-publish-release-package.yml +++ b/.github/workflows/build-and-publish-release-package.yml @@ -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 diff --git a/.github/workflows/build-and-push-docker-image.yml b/.github/workflows/build-and-push-docker-image.yml index e01ab2b4..45112b37 100644 --- a/.github/workflows/build-and-push-docker-image.yml +++ b/.github/workflows/build-and-push-docker-image.yml @@ -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 diff --git a/.github/workflows/build-and-push-docker-nightly-image.yml b/.github/workflows/build-and-push-docker-nightly-image.yml index 33305e1f..7858372f 100644 --- a/.github/workflows/build-and-push-docker-nightly-image.yml +++ b/.github/workflows/build-and-push-docker-nightly-image.yml @@ -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 diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 58ba33ad..d16f51c9 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -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: diff --git a/Dockerfile b/Dockerfile index 575fa822..dc8d1338 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/client/src/api/http.js b/client/src/api/http.js index 4d5ceca1..f48d03a0 100755 --- a/client/src/api/http.js +++ b/client/src/api/http.js @@ -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, diff --git a/client/src/api/socket.js b/client/src/api/socket.js index 81b9ccd8..4a962d5b 100755 --- a/client/src/api/socket.js +++ b/client/src/api/socket.js @@ -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; diff --git a/client/src/components/common/Linkify/Link.jsx b/client/src/components/common/Linkify/Link.jsx index ff1d15c5..80122b73 100644 --- a/client/src/components/common/Linkify/Link.jsx +++ b/client/src/components/common/Linkify/Link.jsx @@ -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) { diff --git a/client/src/configs/markdown-plugins/link.js b/client/src/configs/markdown-plugins/link.js index 09f03fa6..b0463a9a 100644 --- a/client/src/configs/markdown-plugins/link.js +++ b/client/src/configs/markdown-plugins/link.js @@ -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) { diff --git a/client/src/constants/Config.js b/client/src/constants/Config.js index e48c58ab..029e75ee 100755 --- a/client/src/constants/Config.js +++ b/client/src/constants/Config.js @@ -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, diff --git a/client/src/constants/Paths.js b/client/src/constants/Paths.js index 91bd78d0..940eb932 100755 --- a/client/src/constants/Paths.js +++ b/client/src/constants/Paths.js @@ -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, diff --git a/client/src/utils/access-token-storage.js b/client/src/utils/access-token-storage.js index e07924f4..0ed81641 100755 --- a/client/src/utils/access-token-storage.js +++ b/client/src/utils/access-token-storage.js @@ -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 = () => { diff --git a/client/tests/setup-symlinks.sh b/client/tests/setup-symlinks.sh index f7f57f70..2b068b2b 100755 --- a/client/tests/setup-symlinks.sh +++ b/client/tests/setup-symlinks.sh @@ -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." diff --git a/client/vite.config.js b/client/vite.config.js index 231cd045..60245fa3 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -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('', " \n "); + + 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']), diff --git a/server/.buildignore b/server/.buildignore index 9f527b0b..43a420a5 100644 --- a/server/.buildignore +++ b/server/.buildignore @@ -20,7 +20,7 @@ test .tmp .venv -views/index.html +views/index.ejs data/* diff --git a/server/.gitignore b/server/.gitignore index 1ff70729..bb317dac 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -134,7 +134,7 @@ swagger.json dist logs -views/index.html +views/index.ejs data/* !data/.gitkeep diff --git a/server/api/controllers/index.js b/server/api/controllers/index.js new file mode 100644 index 00000000..57b636f6 --- /dev/null +++ b/server/api/controllers/index.js @@ -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, + }; + }, +}; diff --git a/server/api/helpers/utils/clear-http-only-token-cookie.js b/server/api/helpers/utils/clear-http-only-token-cookie.js index bcd7de35..c95ec98b 100644 --- a/server/api/helpers/utils/clear-http-only-token-cookie.js +++ b/server/api/helpers/utils/clear-http-only-token-cookie.js @@ -15,7 +15,7 @@ module.exports = { fn(inputs) { inputs.response.clearCookie('httpOnlyToken', { - path: sails.config.custom.baseUrlPath, + path: sails.config.custom.baseUrlPath || '/', }); }, }; diff --git a/server/api/helpers/utils/set-http-only-token-cookie.js b/server/api/helpers/utils/set-http-only-token-cookie.js index 71867ce0..b13a648e 100644 --- a/server/api/helpers/utils/set-http-only-token-cookie.js +++ b/server/api/helpers/utils/set-http-only-token-cookie.js @@ -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', diff --git a/server/config/custom.js b/server/config/custom.js index 5378e407..5db04627 100644 --- a/server/config/custom.js +++ b/server/config/custom.js @@ -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), diff --git a/server/config/policies.js b/server/config/policies.js index 9c62dd20..b2a64c3b 100644 --- a/server/config/policies.js +++ b/server/config/policies.js @@ -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, diff --git a/server/config/routes.js b/server/config/routes.js index 38cc16aa..4f5519cf 100644 --- a/server/config/routes.js +++ b/server/config/routes.js @@ -266,7 +266,7 @@ module.exports.routes = { }, 'GET /*': { - view: 'index', + action: 'index', skipAssets: true, }, }; diff --git a/server/config/views.js b/server/config/views.js index 55ec7950..c9889c6b 100644 --- a/server/config/views.js +++ b/server/config/views.js @@ -24,7 +24,7 @@ module.exports.views = { * */ - extension: 'html', + // extension: 'ejs', /** *