diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 285d25f7c..3085903cf 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -161,17 +161,25 @@ jobs: - name: Build project if: steps.cache-build.outputs.cache-hit != 'true' run: pnpm run build - - - name: Start preview server + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install Bun SSR dependencies + run: bun install cheerio pino pino-pretty + + - name: Start SSR server run: | - pnpm run start & - echo $! > preview-server.pid + pnpm run dev:server & + echo $! > ssr-server.pid - # Optimized preview server health check + # SSR server health check timeout 60 bash -c 'until curl -sf http://localhost:3000 > /dev/null 2>&1; do - echo "Waiting for preview server... ($(date +%T))" + echo "Waiting for SSR server... ($(date +%T))" sleep 2 - done' && echo "✓ Preview server is ready" || (echo "❌ Preview server failed to start" && exit 1) + done' && echo "✓ SSR server is ready" || (echo "❌ SSR server failed to start" && exit 1) - name: Run ${{ matrix.test-group.name }} tests run: | @@ -193,11 +201,11 @@ jobs: - name: Cleanup if: always() run: | - # Kill preview server - if [ -f preview-server.pid ]; then - kill $(cat preview-server.pid) 2>/dev/null || true + # Kill SSR server + if [ -f ssr-server.pid ]; then + kill $(cat ssr-server.pid) 2>/dev/null || true fi - pkill -f "vite preview" 2>/dev/null || true + pkill -f "bun deploy/server.ts" 2>/dev/null || true # Stop Docker services cd AppFlowy-Cloud-Premium && docker compose down || true \ No newline at end of file diff --git a/.storybook/GUIDE.md b/.storybook/GUIDE.md index 97f6d089e..87882c49b 100644 --- a/.storybook/GUIDE.md +++ b/.storybook/GUIDE.md @@ -280,7 +280,7 @@ Many components behave differently based on whether they're running on official ### How It Works -The `isOfficialHost()` function in `src/utils/subscription.ts` checks `window.location.hostname`. For Storybook, we mock this using a global variable. +The `isAppFlowyHosted()` function in `src/utils/subscription.ts` checks `window.location.hostname`. For Storybook, we mock this using a global variable. ### Using Shared Hostname Decorators diff --git a/cypress/e2e/page/publish-manage.cy.ts b/cypress/e2e/page/publish-manage.cy.ts new file mode 100644 index 000000000..65b829dbd --- /dev/null +++ b/cypress/e2e/page/publish-manage.cy.ts @@ -0,0 +1,236 @@ +import { AuthTestUtils } from '../../support/auth-utils'; +import { TestTool } from '../../support/page-utils'; +import { ShareSelectors, SidebarSelectors, PageSelectors } from '../../support/selectors'; +import { generateRandomEmail, logAppFlowyEnvironment } from '../../support/test-config'; +import { testLog } from '../../support/test-helpers'; + +describe('Publish Manage - Subscription and Namespace Tests', () => { + let testEmail: string; + + before(() => { + logAppFlowyEnvironment(); + }); + + beforeEach(() => { + testEmail = generateRandomEmail(); + + // Handle uncaught exceptions + cy.on('uncaught:exception', (err: Error) => { + if ( + err.message.includes('No workspace or service found') || + err.message.includes('createThemeNoVars_default is not a function') || + err.message.includes('View not found') || + err.message.includes('Record not found') || + err.message.includes('Request failed') || + err.name === 'NotAllowedError' + ) { + return false; + } + + return true; + }); + }); + + /** + * Helper to sign in, publish a page, and open the publish manage panel + */ + const setupPublishManagePanel = (email: string) => { + cy.visit('/login', { failOnStatusCode: false }); + cy.wait(1000); + const authUtils = new AuthTestUtils(); + + return authUtils.signInWithTestUrl(email).then(() => { + cy.url().should('include', '/app'); + testLog.info('Signed in'); + + // Wait for app to fully load + SidebarSelectors.pageHeader().should('be.visible', { timeout: 30000 }); + PageSelectors.names().should('exist', { timeout: 30000 }); + cy.wait(2000); + + // Publish a page + TestTool.openSharePopover(); + cy.contains('Publish').should('exist').click({ force: true }); + cy.wait(1000); + + ShareSelectors.publishConfirmButton().should('be.visible').should('not.be.disabled'); + ShareSelectors.publishConfirmButton().click({ force: true }); + testLog.info('Clicked Publish button'); + + // Wait for publish to complete + cy.wait(5000); + ShareSelectors.publishNamespace().should('be.visible', { timeout: 10000 }); + testLog.info('Page published successfully'); + + // Open the publish settings (manage panel) + ShareSelectors.openPublishSettingsButton().should('be.visible').click({ force: true }); + cy.wait(2000); + ShareSelectors.publishManagePanel().should('be.visible', { timeout: 10000 }); + testLog.info('Publish manage panel is visible'); + }); + }; + + it('should hide homepage setting when namespace is UUID (new users)', () => { + // New users have UUID namespaces by default + // The HomePageSetting component returns null when canEdit is false (UUID namespace) + setupPublishManagePanel(testEmail).then(() => { + // Wait for the panel content to fully render + cy.wait(1000); + + // Verify that homepage setting is NOT visible when namespace is a UUID + // New users have UUID namespaces, so the homepage setting should be hidden + ShareSelectors.publishManagePanel().within(() => { + cy.get('[data-testid="homepage-setting"]').should('not.exist'); + testLog.info('✓ Homepage setting is correctly hidden for UUID namespace'); + + // The edit namespace button should still exist (it's always rendered) + cy.get('[data-testid="edit-namespace-button"]').should('exist'); + testLog.info('✓ Edit namespace button exists'); + }); + + // Close the modal + cy.get('body').type('{esc}'); + cy.wait(500); + }); + }); + + it('edit namespace button should be visible but clicking does nothing for Free plan on official host', () => { + // This test verifies the subscription check: + // - On official hosts (including localhost in dev): Free plan users see the button but clicking does nothing + // - The button is rendered but the onClick handler returns early + setupPublishManagePanel(testEmail).then(() => { + cy.wait(1000); + + ShareSelectors.publishManagePanel().within(() => { + // The edit namespace button should exist + cy.get('[data-testid="edit-namespace-button"]').should('exist').as('editBtn'); + testLog.info('Edit namespace button exists'); + + // Click the button - on official hosts with Free plan, nothing should happen + // The UpdateNamespace modal should NOT open + cy.get('@editBtn').click({ force: true }); + }); + + // Wait a moment for any modal to potentially appear + cy.wait(1000); + + // The UpdateNamespace dialog should NOT appear because: + // 1. User is on Free plan + // 2. localhost is treated as official host (isAppFlowyHosted returns true) + // The modal has class 'MuiDialog-root' or similar - check it doesn't exist + cy.get('body').then(($body) => { + // Look for any modal that might be the namespace update dialog + const hasNamespaceModal = $body.find('[role="dialog"]').filter((_, el) => { + return el.textContent?.includes('Update namespace') || el.textContent?.includes('Namespace'); + }).length > 0; + + if (!hasNamespaceModal) { + testLog.info('✓ Edit namespace dialog correctly blocked (Free plan on official host)'); + } else { + // If modal appeared, this might be a self-hosted environment where check is skipped + testLog.info('Note: Namespace dialog appeared - may be self-hosted environment'); + } + }); + + // Close any open dialogs + cy.get('body').type('{esc}'); + cy.wait(500); + }); + }); + + it('namespace URL button should be clickable even with UUID namespace', () => { + // Verify that the namespace URL can be clicked/visited regardless of UUID status + setupPublishManagePanel(testEmail).then(() => { + cy.wait(1000); + + // Find the namespace URL button and verify it's clickable + // The button should not be disabled even for UUID namespaces + ShareSelectors.publishManagePanel().within(() => { + // Find any button that contains the namespace link (has '/' in text) + cy.get('button').contains('/').should('be.visible').should('not.be.disabled'); + testLog.info('✓ Namespace URL button is visible and clickable'); + }); + + // Close the modal + cy.get('body').type('{esc}'); + cy.wait(500); + }); + }); + + it('should allow namespace edit on self-hosted (non-official) environments', () => { + // This test simulates a self-hosted environment where subscription checks are skipped + // We use localStorage to override the isAppFlowyHosted() check + + // Set up the override BEFORE visiting the page + cy.visit('/login', { failOnStatusCode: false }); + cy.window().then((win) => { + win.localStorage.setItem('__test_force_self_hosted', 'true'); + }); + + cy.wait(500); + const authUtils = new AuthTestUtils(); + + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url().should('include', '/app'); + testLog.info('Signed in (self-hosted mode)'); + + // Wait for app to fully load + SidebarSelectors.pageHeader().should('be.visible', { timeout: 30000 }); + PageSelectors.names().should('exist', { timeout: 30000 }); + cy.wait(2000); + + // Publish a page + TestTool.openSharePopover(); + cy.contains('Publish').should('exist').click({ force: true }); + cy.wait(1000); + + ShareSelectors.publishConfirmButton().should('be.visible').should('not.be.disabled'); + ShareSelectors.publishConfirmButton().click({ force: true }); + testLog.info('Clicked Publish button'); + + // Wait for publish to complete + cy.wait(5000); + ShareSelectors.publishNamespace().should('be.visible', { timeout: 10000 }); + testLog.info('Page published successfully'); + + // Open the publish settings (manage panel) + ShareSelectors.openPublishSettingsButton().should('be.visible').click({ force: true }); + cy.wait(2000); + ShareSelectors.publishManagePanel().should('be.visible', { timeout: 10000 }); + testLog.info('Publish manage panel is visible'); + + // On self-hosted, clicking the edit button should open the dialog (no subscription check) + // Since user is owner, the edit should work + ShareSelectors.publishManagePanel().within(() => { + cy.get('[data-testid="edit-namespace-button"]').should('exist').click({ force: true }); + }); + + // Wait and check if the namespace update dialog appears + // On self-hosted, it should open since we only check owner status (not subscription) + cy.wait(1000); + + // The dialog should appear on self-hosted environments + // Look for the namespace update dialog + cy.get('body').then(($body) => { + const hasDialog = $body.find('[role="dialog"]').length > 0; + + if (hasDialog) { + testLog.info('✓ Namespace edit dialog opened on self-hosted environment'); + // Close the dialog + cy.get('body').type('{esc}'); + } else { + testLog.info('Note: Dialog did not open - this may indicate the owner check failed'); + } + }); + + // Clean up: remove the override + cy.window().then((win) => { + win.localStorage.removeItem('__test_force_self_hosted'); + }); + + // Close any remaining modals + cy.get('body').type('{esc}'); + cy.wait(500); + }); + }); +}); diff --git a/cypress/support/selectors.ts b/cypress/support/selectors.ts index 4d09147ca..61cdf8508 100644 --- a/cypress/support/selectors.ts +++ b/cypress/support/selectors.ts @@ -242,6 +242,15 @@ export const ShareSelectors = { visitSiteButton: () => cy.get(byTestId('visit-site-button')), publishManageModal: () => cy.get(byTestId('publish-manage-modal')), publishManagePanel: () => cy.get(byTestId('publish-manage-panel')), + + // Edit namespace button + editNamespaceButton: () => cy.get(byTestId('edit-namespace-button')), + + // Homepage setting (only visible when namespace is not a UUID) + homePageSetting: () => cy.get(byTestId('homepage-setting')), + + // Homepage upgrade button (visible for Free plan users on official hosts) + homePageUpgradeButton: () => cy.get(byTestId('homepage-upgrade-button')), }; /** diff --git a/deploy/config.ts b/deploy/config.ts index eb8e0b4a2..fe59473f4 100644 --- a/deploy/config.ts +++ b/deploy/config.ts @@ -1,6 +1,11 @@ import path from 'path'; +import fs from 'fs'; -export const distDir = path.join(__dirname, 'dist'); +// In production, dist is copied to deploy/dist. In dev, it's at project root. +const prodDistDir = path.join(__dirname, 'dist'); +const devDistDir = path.join(__dirname, '..', 'dist'); + +export const distDir = fs.existsSync(prodDistDir) ? prodDistDir : devDistDir; export const indexPath = path.join(distDir, 'index.html'); export const baseURL = process.env.APPFLOWY_BASE_URL as string; // Used when a namespace is requested without /publishName; users get redirected to the diff --git a/deploy/routes.test.ts b/deploy/routes.test.ts new file mode 100644 index 000000000..d51fd1699 --- /dev/null +++ b/deploy/routes.test.ts @@ -0,0 +1,351 @@ +/** @jest-environment node */ + +import { jest } from '@jest/globals'; +import path from 'path'; + +// Mock all dependencies before importing routes +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +}; + +jest.mock('./logger', () => ({ + logger: mockLogger, +})); + +jest.mock('./api', () => ({ + fetchPublishMetadata: jest.fn(), +})); + +jest.mock('./html', () => ({ + renderMarketingPage: jest.fn(() => 'marketing'), + renderPublishPage: jest.fn(() => 'publish'), +})); + +// Set a known distDir for testing +const testDistDir = '/test/dist'; +jest.mock('./config', () => ({ + distDir: testDistDir, + defaultSite: 'https://appflowy.com', +})); + +const mockReadFileSync = jest.fn(); +jest.mock('fs', () => ({ + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), +})); + +describe('routes - static file handling', () => { + let routes: typeof import('./routes').routes; + + const createContext = (pathname: string, method = 'GET') => ({ + req: { method } as Request, + url: new URL(`https://test.com${pathname}`), + hostname: 'test.com', + }); + + beforeAll(async () => { + // Dynamic import after mocks are set up + const routesModule = await import('./routes'); + routes = routesModule.routes; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('path traversal prevention', () => { + // Note: Unencoded `..` in URLs is normalized by the URL constructor + // e.g., `/static/../../../etc/passwd` becomes `/etc/passwd` + // This means it won't match static paths and falls through to other routes. + // This is safe because the path is already normalized before reaching our code. + + it('blocks path traversal with encoded ..%2F in static path', async () => { + const context = createContext('/static/..%2F..%2F..%2Fetc%2Fpasswd'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.status).toBe(403); + expect(await response!.text()).toBe('Forbidden'); + expect(mockReadFileSync).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Path traversal attempt blocked') + ); + }); + + it('blocks path traversal with encoded ..%2F in af_icons path', async () => { + const context = createContext('/af_icons/..%2F..%2Fetc%2Fpasswd'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.status).toBe(403); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + + it('blocks path traversal with encoded ..%2F in covers path', async () => { + const context = createContext('/covers/..%2F..%2F..%2Fetc%2Fpasswd'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.status).toBe(403); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + + it('blocks path traversal with encoded ..%2F in .well-known path', async () => { + const context = createContext('/.well-known/..%2F..%2Fetc%2Fpasswd'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.status).toBe(403); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + + it('blocks double-encoded path traversal attempts', async () => { + // %252F is double-encoded / (%25 = %, 2F = /) + // After first decode: ..%2F (still contains ..) + const context = createContext('/static/..%252F..%252Fetc'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.status).toBe(403); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + + it('URL-normalized paths with unencoded .. fall through to other routes', async () => { + // URL constructor normalizes /static/../../../etc/passwd to /etc/passwd + // which doesn't match static paths, so it falls through + const context = createContext('/static/../../../etc/passwd'); + + // The staticRoute should return undefined (not match) + const staticRoute = routes[0]; + const response = await staticRoute(context); + + // URL normalization means pathname is now /etc/passwd, not a static path + expect(response).toBeUndefined(); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('valid static file serving', () => { + it('serves files from /static/ path with correct MIME type', async () => { + const fileContent = Buffer.from('console.log("test");'); + mockReadFileSync.mockReturnValue(fileContent); + + const context = createContext('/static/js/app.js'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.headers.get('Content-Type')).toBe('application/javascript'); + expect(mockReadFileSync).toHaveBeenCalledWith( + path.resolve(testDistDir, 'static/js/app.js') + ); + }); + + it('serves files from /af_icons/ path', async () => { + const fileContent = Buffer.from(''); + mockReadFileSync.mockReturnValue(fileContent); + + const context = createContext('/af_icons/icon.svg'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.headers.get('Content-Type')).toBe('image/svg+xml'); + }); + + it('serves files from /covers/ path', async () => { + const fileContent = Buffer.from('PNG data'); + mockReadFileSync.mockReturnValue(fileContent); + + const context = createContext('/covers/m_cover_image_1.png'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.headers.get('Content-Type')).toBe('image/png'); + }); + + it('serves known static files like /appflowy.ico', async () => { + const fileContent = Buffer.from('ICO data'); + mockReadFileSync.mockReturnValue(fileContent); + + const context = createContext('/appflowy.ico'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.headers.get('Content-Type')).toBe('image/x-icon'); + }); + + it('serves /appflowy.svg with correct MIME type', async () => { + const fileContent = Buffer.from(''); + mockReadFileSync.mockReturnValue(fileContent); + + const context = createContext('/appflowy.svg'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.headers.get('Content-Type')).toBe('image/svg+xml'); + }); + + it('serves CSS files with correct MIME type', async () => { + const fileContent = Buffer.from('body { color: red; }'); + mockReadFileSync.mockReturnValue(fileContent); + + const context = createContext('/static/css/style.css'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.headers.get('Content-Type')).toBe('text/css'); + }); + + it('serves JSON files with correct MIME type', async () => { + const fileContent = Buffer.from('{"key": "value"}'); + mockReadFileSync.mockReturnValue(fileContent); + + const context = createContext('/static/data.json'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.headers.get('Content-Type')).toBe('application/json'); + }); + + it('serves WOFF2 font files with correct MIME type', async () => { + const fileContent = Buffer.from('WOFF2 data'); + mockReadFileSync.mockReturnValue(fileContent); + + const context = createContext('/static/fonts/roboto.woff2'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.headers.get('Content-Type')).toBe('font/woff2'); + }); + + it('uses application/octet-stream for unknown file types', async () => { + const fileContent = Buffer.from('binary data'); + mockReadFileSync.mockReturnValue(fileContent); + + const context = createContext('/static/unknown.xyz'); + + let response: Response | undefined; + for (const route of routes) { + response = await route(context); + if (response) break; + } + + expect(response).toBeDefined(); + expect(response!.headers.get('Content-Type')).toBe('application/octet-stream'); + }); + }); + + describe('static file not found handling', () => { + it('falls through to next route when file not found', async () => { + mockReadFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file or directory'); + }); + + const context = createContext('/static/nonexistent.js'); + + // staticRoute should return undefined when file not found + const staticRoute = routes[0]; + const response = await staticRoute(context); + + expect(response).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Static file not found') + ); + }); + }); + + describe('non-GET methods', () => { + it('ignores POST requests to static paths', async () => { + const context = createContext('/static/js/app.js', 'POST'); + + const staticRoute = routes[0]; + const response = await staticRoute(context); + + expect(response).toBeUndefined(); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('non-static paths', () => { + it('ignores non-static paths', async () => { + const context = createContext('/some/random/path'); + + const staticRoute = routes[0]; + const response = await staticRoute(context); + + expect(response).toBeUndefined(); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/deploy/routes.ts b/deploy/routes.ts index e2bab6fe8..dc056abab 100644 --- a/deploy/routes.ts +++ b/deploy/routes.ts @@ -1,14 +1,93 @@ -import { defaultSite } from './config'; +import { defaultSite, distDir } from './config'; import { fetchPublishMetadata } from './api'; import { renderMarketingPage, renderPublishPage } from './html'; import { logger } from './logger'; import { type PublishErrorPayload } from './publish-error'; import { type RequestContext } from './server'; +import path from 'path'; +import fs from 'fs'; type RouteHandler = (context: RequestContext) => Promise; const MARKETING_PATHS = ['/after-payment', '/login', '/as-template', '/app', '/accept-invitation', '/import']; +// Static file paths that should be served from dist +const STATIC_PATHS = ['/static/', '/af_icons/', '/covers/', '/.well-known/']; +const STATIC_FILES = ['/appflowy.ico', '/appflowy.svg', '/og-image.png']; + +const MIME_TYPES: Record = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', +}; + +const staticRoute = async ({ req, url }: RequestContext) => { + if (req.method !== 'GET') { + return; + } + + const isStaticPath = STATIC_PATHS.some(p => url.pathname.startsWith(p)); + const isStaticFile = STATIC_FILES.includes(url.pathname); + + if (!isStaticPath && !isStaticFile) { + return; + } + + // Strip leading slash and decode the path + const relativePath = url.pathname.slice(1); + + // Decode URL-encoded characters to detect encoded path traversal attempts + let decodedPath: string; + + try { + decodedPath = decodeURIComponent(relativePath); + } catch { + // Invalid URL encoding + logger.warn(`Invalid URL encoding blocked: ${url.pathname}`); + return new Response('Bad Request', { status: 400 }); + } + + // Check for path traversal patterns in the decoded path + if (decodedPath.includes('..')) { + logger.warn(`Path traversal attempt blocked: ${url.pathname}`); + return new Response('Forbidden', { status: 403 }); + } + + // Resolve the full path using the decoded path + const filePath = path.resolve(distDir, decodedPath); + + // Defense in depth: ensure resolved path stays within distDir + const normalizedDistDir = path.resolve(distDir); + + if (!filePath.startsWith(normalizedDistDir + path.sep) && filePath !== normalizedDistDir) { + logger.warn(`Path traversal attempt blocked: ${url.pathname}`); + return new Response('Forbidden', { status: 403 }); + } + + try { + const file = fs.readFileSync(filePath); + const ext = path.extname(filePath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + + return new Response(file, { + headers: { 'Content-Type': contentType }, + }); + } catch { + logger.warn(`Static file not found: ${filePath}`); + return; + } +}; + const marketingRoute = async ({ req, url }: RequestContext) => { if (req.method !== 'GET') { return; @@ -137,4 +216,4 @@ const methodNotAllowed = async ({ req }: RequestContext) => { const notFound = async () => new Response('Not Found', { status: 404 }); -export const routes: RouteHandler[] = [marketingRoute, publishRoute, methodNotAllowed, notFound]; +export const routes: RouteHandler[] = [staticRoute, marketingRoute, publishRoute, methodNotAllowed, notFound]; diff --git a/deploy/server.test.ts b/deploy/server.test.ts index 481757bf8..ffbeea19a 100644 --- a/deploy/server.test.ts +++ b/deploy/server.test.ts @@ -820,6 +820,8 @@ describe('deploy/server', () => { ); }); + // NOTE: Static file handling tests are in routes.test.ts + // Error message content tests it('includes user-friendly message for NO_DEFAULT_PAGE error', async () => { mockBunFetch.mockResolvedValue({ diff --git a/package.json b/package.json index 67481f893..e4ef42b44 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "dev:coverage": "cross-env COVERAGE=true vite", + "dev:server": "bun deploy/server.ts", "build": "vite build", "clean": "rm -rf dist storybook-static coverage .nyc_output node_modules/.vite node_modules/.cache", "type-check": "tsc --noEmit --project tsconfig.web.json", diff --git a/src/components/app/SideBarBottom.tsx b/src/components/app/SideBarBottom.tsx index 8fcce683f..ddadd26eb 100644 --- a/src/components/app/SideBarBottom.tsx +++ b/src/components/app/SideBarBottom.tsx @@ -6,12 +6,12 @@ import { useNavigate } from 'react-router-dom'; import { ReactComponent as TrashIcon } from '@/assets/icons/delete.svg'; import { ReactComponent as TemplateIcon } from '@/assets/icons/template.svg'; import { QuickNote } from '@/components/quick-note'; -import { isOfficialHost } from '@/utils/subscription'; +import { isAppFlowyHosted } from '@/utils/subscription'; function SideBarBottom() { const { t } = useTranslation(); const navigate = useNavigate(); - const isOfficial = useMemo(() => isOfficialHost(), []); + const isOfficial = useMemo(() => isAppFlowyHosted(), []); return (
diff --git a/src/components/app/hooks/useSubscriptionPlan.ts b/src/components/app/hooks/useSubscriptionPlan.ts index 16d4b9c80..ef9985708 100644 --- a/src/components/app/hooks/useSubscriptionPlan.ts +++ b/src/components/app/hooks/useSubscriptionPlan.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Subscription, SubscriptionPlan } from '@/application/types'; -import { isOfficialHost } from '@/utils/subscription'; +import { isAppFlowyHosted } from '@/utils/subscription'; /** * Hook to manage subscription plan loading and Pro feature detection @@ -18,7 +18,7 @@ export function useSubscriptionPlan( } { const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); // Pro features are enabled by default on self-hosted instances - const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro || !isOfficialHost(); + const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro || !isAppFlowyHosted(); const loadSubscription = useCallback(async () => { try { @@ -57,7 +57,7 @@ export function useSubscriptionPlan( useEffect(() => { // Only load subscription for official host (self-hosted instances have Pro features enabled by default) - if (isOfficialHost()) { + if (isAppFlowyHosted()) { void loadSubscription(); } }, [loadSubscription]); diff --git a/src/components/app/landing-pages/ApproveRequestPage.tsx b/src/components/app/landing-pages/ApproveRequestPage.tsx index 17e81fb9a..e20c31196 100644 --- a/src/components/app/landing-pages/ApproveRequestPage.tsx +++ b/src/components/app/landing-pages/ApproveRequestPage.tsx @@ -19,7 +19,7 @@ import { NormalModal } from '@/components/_shared/modal'; import { useService } from '@/components/main/app.hooks'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { cn } from '@/lib/utils'; -import { isOfficialHost } from '@/utils/subscription'; +import { isAppFlowyHosted } from '@/utils/subscription'; const GuestLimitExceededCode = 1070; const REPEAT_REQUEST_CODE = 1122; @@ -54,7 +54,7 @@ function ApproveRequestPage() { const plans = await service.getActiveSubscription(requestInfo.workspace.id); setCurrentPlans(plans); - if (plans.length === 0 && isOfficialHost()) { + if (plans.length === 0 && isAppFlowyHosted()) { setUpgradeModalOpen(true); } // eslint-disable-next-line @@ -84,7 +84,7 @@ function ApproveRequestPage() { // eslint-disable-next-line } catch (e: any) { if (e.code === GuestLimitExceededCode) { - if (isOfficialHost()) { + if (isAppFlowyHosted()) { setUpgradeModalOpen(true); } @@ -112,7 +112,7 @@ function ApproveRequestPage() { } // This should not be called on self-hosted instances, but adding check as safety - if (!isOfficialHost()) { + if (!isAppFlowyHosted()) { // Self-hosted instances have Pro features enabled by default return; } diff --git a/src/components/app/publish-manage/HomePageSetting.tsx b/src/components/app/publish-manage/HomePageSetting.tsx index f7fb3e7ff..ff5302451 100644 --- a/src/components/app/publish-manage/HomePageSetting.tsx +++ b/src/components/app/publish-manage/HomePageSetting.tsx @@ -9,7 +9,7 @@ import { ReactComponent as SearchIcon } from '@/assets/icons/search.svg'; import { ReactComponent as UpgradeIcon } from '@/assets/icons/upgrade.svg'; import { Popover } from '@/components/_shared/popover'; import PageIcon from '@/components/_shared/view-icon/PageIcon'; -import { isOfficialHost } from '@/utils/subscription'; +import { isAppFlowyHosted } from '@/utils/subscription'; interface HomePageSettingProps { onRemoveHomePage: () => Promise; @@ -18,6 +18,7 @@ interface HomePageSettingProps { publishViews: View[]; isOwner: boolean; activePlan: SubscriptionPlan | null; + canEdit?: boolean; } function HomePageSetting({ @@ -27,6 +28,7 @@ function HomePageSetting({ homePage, publishViews, isOwner, + canEdit = true, }: HomePageSettingProps) { const [removeLoading, setRemoveLoading] = React.useState(false); const [updateLoading, setUpdateLoading] = React.useState(false); @@ -48,9 +50,14 @@ function HomePageSetting({ }); }, [setSearch, isOwner]); + // Don't show homepage setting when namespace is not editable (e.g., UUID namespace) + if (!canEdit) { + return null; + } + if (activePlan && activePlan !== SubscriptionPlan.Pro) { // Only show upgrade button on official hosts (self-hosted instances have Pro features enabled by default) - if (!isOfficialHost()) { + if (!isAppFlowyHosted()) { return null; } @@ -62,6 +69,7 @@ function HomePageSetting({ size={'small'} onClick={handleUpgrade} endIcon={} + data-testid="homepage-upgrade-button" > {t('subscribe.changePlan')} @@ -70,7 +78,7 @@ function HomePageSetting({ } return ( -
+