diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index 2d725bf28a..e894a91703 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -147,7 +147,10 @@ jobs: fi test-server: - runs-on: budi-tubby-tornado-quad-core-300gb + runs-on: ubuntu-latest + strategy: + matrix: + datasource: [mssql, mysql, postgres, mongodb, mariadb, oracle, none] steps: - name: Checkout repo uses: actions/checkout@v4 @@ -170,12 +173,19 @@ jobs: - name: Pull testcontainers images run: | - docker pull mcr.microsoft.com/mssql/server@${{ steps.dotenv.outputs.MSSQL_SHA }} & - docker pull mysql@${{ steps.dotenv.outputs.MYSQL_SHA }} & - docker pull postgres@${{ steps.dotenv.outputs.POSTGRES_SHA }} & - docker pull mongo@${{ steps.dotenv.outputs.MONGODB_SHA }} & - docker pull mariadb@${{ steps.dotenv.outputs.MARIADB_SHA }} & - docker pull budibase/oracle-database:23.2-slim-faststart & + if [ "${{ matrix.datasource }}" == "mssql" ]; then + docker pull mcr.microsoft.com/mssql/server@${{ steps.dotenv.outputs.MSSQL_SHA }} + elif [ "${{ matrix.datasource }}" == "mysql" ]; then + docker pull mysql@${{ steps.dotenv.outputs.MYSQL_SHA }} + elif [ "${{ matrix.datasource }}" == "postgres" ]; then + docker pull postgres@${{ steps.dotenv.outputs.POSTGRES_SHA }} + elif [ "${{ matrix.datasource }}" == "mongodb" ]; then + docker pull mongo@${{ steps.dotenv.outputs.MONGODB_SHA }} + elif [ "${{ matrix.datasource }}" == "mariadb" ]; then + docker pull mariadb@${{ steps.dotenv.outputs.MARIADB_SHA }} + elif [ "${{ matrix.datasource }}" == "oracle" ]; then + docker pull budibase/oracle-database:23.2-slim-faststart + fi docker pull minio/minio & docker pull redis & docker pull testcontainers/ryuk:0.5.1 & @@ -186,13 +196,25 @@ jobs: - run: yarn --frozen-lockfile - name: Test server + env: + DATASOURCE: ${{ matrix.datasource }} run: | if ${{ env.ONLY_AFFECTED_TASKS }}; then - node scripts/run-affected.js --task=test --scope=@budibase/server --since=${{ env.NX_BASE_BRANCH }} - else - yarn test --scope=@budibase/server + AFFECTED=$(yarn --silent nx show projects --affected -t test --base=${{ env.NX_BASE_BRANCH }} -p @budibase/server) + if [ -z "$AFFECTED" ]; then + echo "No affected tests to run" + exit 0 + fi fi + FILTER="./src/tests/filters/datasource-tests.js" + if [ "${{ matrix.datasource }}" == "none" ]; then + FILTER="./src/tests/filters/non-datasource-tests.js" + fi + + cd packages/server + yarn test --filter $FILTER --passWithNoTests + check-pro-submodule: runs-on: ubuntu-latest if: inputs.run_as_oss != true && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase') diff --git a/globalSetup.ts b/globalSetup.ts index 5d8b0381c0..07a0cec5e2 100644 --- a/globalSetup.ts +++ b/globalSetup.ts @@ -62,6 +62,7 @@ export default async function setup() { }, ]) .withLabels({ "com.budibase": "true" }) + .withTmpFs({ "/data": "rw" }) .withReuse() .withWaitStrategy( Wait.forSuccessfulCommand( @@ -72,6 +73,7 @@ export default async function setup() { const minio = new GenericContainer("minio/minio") .withExposedPorts(9000) .withCommand(["server", "/data"]) + .withTmpFs({ "/data": "rw" }) .withEnvironment({ MINIO_ACCESS_KEY: "budibase", MINIO_SECRET_KEY: "budibase", diff --git a/hosting/proxy/nginx.prod.conf b/hosting/proxy/nginx.prod.conf index f18bae09a4..c5d378afd8 100644 --- a/hosting/proxy/nginx.prod.conf +++ b/hosting/proxy/nginx.prod.conf @@ -50,19 +50,6 @@ http { ignore_invalid_headers off; proxy_buffering off; - set $csp_default "default-src 'self'"; - set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://d2l5prqdbvm3op.cloudfront.net https://us-assets.i.posthog.com"; - set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com"; - set $csp_object "object-src 'none'"; - set $csp_base_uri "base-uri 'self'"; - set $csp_connect "connect-src 'self' https://*.budibase.app https://*.budibaseqa.app https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com https://us.i.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com"; - set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com"; - set $csp_frame "frame-src 'self' https:"; - set $csp_img "img-src http: https: data: blob:"; - set $csp_manifest "manifest-src 'self'"; - set $csp_media "media-src 'self' https://js.intercomcdn.com https://cdn.budi.live"; - set $csp_worker "worker-src blob:"; - error_page 502 503 504 /error.html; location = /error.html { root /usr/share/nginx/html; @@ -73,7 +60,6 @@ http { add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; add_header X-XSS-Protection "1; mode=block" always; - add_header Content-Security-Policy "${csp_default}; ${csp_script}; ${csp_style}; ${csp_object}; ${csp_base_uri}; ${csp_connect}; ${csp_font}; ${csp_frame}; ${csp_img}; ${csp_manifest}; ${csp_media}; ${csp_worker};" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; # upstreams @@ -120,6 +106,12 @@ http { location ~ ^/api/(system|admin|global)/ { proxy_set_header Host $host; + + # Enable buffering for potentially large OIDC configs + proxy_buffering on; + proxy_buffer_size 16k; + proxy_buffers 4 32k; + proxy_pass $worker; } diff --git a/lerna.json b/lerna.json index 8d45ff71ac..e5b0013535 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "3.2.1", + "version": "3.2.5", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/backend-core/src/db/couch/DatabaseImpl.ts b/packages/backend-core/src/db/couch/DatabaseImpl.ts index b807db0ee3..83b9b69d0b 100644 --- a/packages/backend-core/src/db/couch/DatabaseImpl.ts +++ b/packages/backend-core/src/db/couch/DatabaseImpl.ts @@ -10,7 +10,6 @@ import { DatabaseQueryOpts, DBError, Document, - FeatureFlag, isDocument, RowResponse, RowValue, @@ -27,7 +26,6 @@ import { SQLITE_DESIGN_DOC_ID } from "../../constants" import { DDInstrumentedDatabase } from "../instrumentation" import { checkSlashesInUrl } from "../../helpers" import { sqlLog } from "../../sql/utils" -import { flags } from "../../features" const DATABASE_NOT_FOUND = "Database does not exist." @@ -456,10 +454,7 @@ export class DatabaseImpl implements Database { } async destroy() { - if ( - (await flags.isEnabled(FeatureFlag.SQS)) && - (await this.exists(SQLITE_DESIGN_DOC_ID)) - ) { + if (await this.exists(SQLITE_DESIGN_DOC_ID)) { // delete the design document, then run the cleanup operation const definition = await this.get(SQLITE_DESIGN_DOC_ID) // remove all tables - save the definition then trigger a cleanup diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 4cb0a9c731..2763944d2f 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "fs" import { ServiceType } from "@budibase/types" import { cloneDeep } from "lodash" +import { createSecretKey } from "crypto" function isTest() { return isJest() @@ -126,8 +127,12 @@ const environment = { }, BUDIBASE_ENVIRONMENT: process.env.BUDIBASE_ENVIRONMENT, JS_BCRYPT: process.env.JS_BCRYPT, - JWT_SECRET: process.env.JWT_SECRET, - JWT_SECRET_FALLBACK: process.env.JWT_SECRET_FALLBACK, + JWT_SECRET: process.env.JWT_SECRET + ? createSecretKey(Buffer.from(process.env.JWT_SECRET)) + : undefined, + JWT_SECRET_FALLBACK: process.env.JWT_SECRET_FALLBACK + ? createSecretKey(Buffer.from(process.env.JWT_SECRET_FALLBACK)) + : undefined, ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, API_ENCRYPTION_KEY: getAPIEncryptionKey(), COUCH_DB_URL: process.env.COUCH_DB_URL || "http://localhost:4005", @@ -225,6 +230,7 @@ const environment = { OPENAI_API_KEY: process.env.OPENAI_API_KEY, MIN_VERSION_WITHOUT_POWER_ROLE: process.env.MIN_VERSION_WITHOUT_POWER_ROLE || "3.0.0", + DISABLE_CONTENT_SECURITY_POLICY: process.env.DISABLE_CONTENT_SECURITY_POLICY, } export function setEnv(newEnvVars: Partial): () => void { diff --git a/packages/backend-core/src/features/features.ts b/packages/backend-core/src/features/features.ts index de6b9cad8b..b3f016e88a 100644 --- a/packages/backend-core/src/features/features.ts +++ b/packages/backend-core/src/features/features.ts @@ -269,8 +269,6 @@ export class FlagSet, T extends { [key: string]: V }> { export const flags = new FlagSet({ [FeatureFlag.DEFAULT_VALUES]: Flag.boolean(true), [FeatureFlag.AUTOMATION_BRANCHING]: Flag.boolean(true), - [FeatureFlag.SQS]: Flag.boolean(true), - [FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(true), [FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(true), [FeatureFlag.BUDIBASE_AI]: Flag.boolean(true), }) diff --git a/packages/backend-core/src/middleware/contentSecurityPolicy.ts b/packages/backend-core/src/middleware/contentSecurityPolicy.ts new file mode 100644 index 0000000000..d1668d3dd5 --- /dev/null +++ b/packages/backend-core/src/middleware/contentSecurityPolicy.ts @@ -0,0 +1,113 @@ +import crypto from "crypto" + +const CSP_DIRECTIVES = { + "default-src": ["'self'"], + "script-src": [ + "'self'", + "'unsafe-eval'", + "https://*.budibase.net", + "https://cdn.budi.live", + "https://js.intercomcdn.com", + "https://widget.intercom.io", + "https://d2l5prqdbvm3op.cloudfront.net", + "https://us-assets.i.posthog.com", + ], + "style-src": [ + "'self'", + "'unsafe-inline'", + "https://cdn.jsdelivr.net", + "https://fonts.googleapis.com", + "https://rsms.me", + "https://maxcdn.bootstrapcdn.com", + ], + "object-src": ["'none'"], + "base-uri": ["'self'"], + "connect-src": [ + "'self'", + "https://*.budibase.app", + "https://*.budibaseqa.app", + "https://*.budibase.net", + "https://api-iam.intercom.io", + "https://api-ping.intercom.io", + "https://app.posthog.com", + "https://us.i.posthog.com", + "wss://nexus-websocket-a.intercom.io", + "wss://nexus-websocket-b.intercom.io", + "https://nexus-websocket-a.intercom.io", + "https://nexus-websocket-b.intercom.io", + "https://uploads.intercomcdn.com", + "https://uploads.intercomusercontent.com", + "https://*.amazonaws.com", + "https://*.s3.amazonaws.com", + "https://*.s3.us-east-2.amazonaws.com", + "https://*.s3.us-east-1.amazonaws.com", + "https://*.s3.us-west-1.amazonaws.com", + "https://*.s3.us-west-2.amazonaws.com", + "https://*.s3.af-south-1.amazonaws.com", + "https://*.s3.ap-east-1.amazonaws.com", + "https://*.s3.ap-south-1.amazonaws.com", + "https://*.s3.ap-northeast-2.amazonaws.com", + "https://*.s3.ap-southeast-1.amazonaws.com", + "https://*.s3.ap-southeast-2.amazonaws.com", + "https://*.s3.ap-northeast-1.amazonaws.com", + "https://*.s3.ca-central-1.amazonaws.com", + "https://*.s3.cn-north-1.amazonaws.com", + "https://*.s3.cn-northwest-1.amazonaws.com", + "https://*.s3.eu-central-1.amazonaws.com", + "https://*.s3.eu-west-1.amazonaws.com", + "https://*.s3.eu-west-2.amazonaws.com", + "https://*.s3.eu-south-1.amazonaws.com", + "https://*.s3.eu-west-3.amazonaws.com", + "https://*.s3.eu-north-1.amazonaws.com", + "https://*.s3.sa-east-1.amazonaws.com", + "https://*.s3.me-south-1.amazonaws.com", + "https://*.s3.us-gov-east-1.amazonaws.com", + "https://*.s3.us-gov-west-1.amazonaws.com", + "https://api.github.com", + ], + "font-src": [ + "'self'", + "data:", + "https://cdn.jsdelivr.net", + "https://fonts.gstatic.com", + "https://rsms.me", + "https://maxcdn.bootstrapcdn.com", + "https://js.intercomcdn.com", + "https://fonts.intercomcdn.com", + ], + "frame-src": ["'self'", "https:"], + "img-src": ["http:", "https:", "data:", "blob:"], + "manifest-src": ["'self'"], + "media-src": [ + "'self'", + "https://js.intercomcdn.com", + "https://cdn.budi.live", + ], + "worker-src": ["blob:"], +} + +export async function contentSecurityPolicy(ctx: any, next: any) { + try { + const nonce = crypto.randomBytes(16).toString("base64") + + const directives = { ...CSP_DIRECTIVES } + directives["script-src"] = [ + ...CSP_DIRECTIVES["script-src"], + `'nonce-${nonce}'`, + ] + + ctx.state.nonce = nonce + + const cspHeader = Object.entries(directives) + .map(([key, sources]) => `${key} ${sources.join(" ")}`) + .join("; ") + ctx.set("Content-Security-Policy", cspHeader) + await next() + } catch (err: any) { + console.error( + `Error occurred in Content-Security-Policy middleware: ${err}` + ) + } +} + +export default contentSecurityPolicy diff --git a/packages/backend-core/src/middleware/index.ts b/packages/backend-core/src/middleware/index.ts index 20c2125b13..9ee51db45b 100644 --- a/packages/backend-core/src/middleware/index.ts +++ b/packages/backend-core/src/middleware/index.ts @@ -19,5 +19,6 @@ export { default as pino } from "../logging/pino/middleware" export { default as correlation } from "../logging/correlation/middleware" export { default as errorHandling } from "./errorHandling" export { default as querystringToBody } from "./querystringToBody" +export { default as csp } from "./contentSecurityPolicy" export * as joiValidator from "./joi-validator" export { default as ip } from "./ip" diff --git a/packages/backend-core/src/middleware/tests/contentSecurityPolicy.spec.ts b/packages/backend-core/src/middleware/tests/contentSecurityPolicy.spec.ts new file mode 100644 index 0000000000..0c5838e7fe --- /dev/null +++ b/packages/backend-core/src/middleware/tests/contentSecurityPolicy.spec.ts @@ -0,0 +1,75 @@ +import crypto from "crypto" +import contentSecurityPolicy from "../contentSecurityPolicy" + +jest.mock("crypto", () => ({ + randomBytes: jest.fn(), + randomUUID: jest.fn(), +})) + +describe("contentSecurityPolicy middleware", () => { + let ctx: any + let next: any + const mockNonce = "mocked/nonce" + + beforeEach(() => { + ctx = { + state: {}, + set: jest.fn(), + } + next = jest.fn() + // @ts-ignore + crypto.randomBytes.mockReturnValue(Buffer.from(mockNonce, "base64")) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it("should generate a nonce and set it in the script-src directive", async () => { + await contentSecurityPolicy(ctx, next) + + expect(ctx.state.nonce).toBe(mockNonce) + expect(ctx.set).toHaveBeenCalledWith( + "Content-Security-Policy", + expect.stringContaining( + `script-src 'self' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://d2l5prqdbvm3op.cloudfront.net https://us-assets.i.posthog.com 'nonce-${mockNonce}'` + ) + ) + expect(next).toHaveBeenCalled() + }) + + it("should include all CSP directives in the header", async () => { + await contentSecurityPolicy(ctx, next) + + const cspHeader = ctx.set.mock.calls[0][1] + expect(cspHeader).toContain("default-src 'self'") + expect(cspHeader).toContain("script-src 'self' 'unsafe-eval'") + expect(cspHeader).toContain("style-src 'self' 'unsafe-inline'") + expect(cspHeader).toContain("object-src 'none'") + expect(cspHeader).toContain("base-uri 'self'") + expect(cspHeader).toContain("connect-src 'self'") + expect(cspHeader).toContain("font-src 'self'") + expect(cspHeader).toContain("frame-src 'self'") + expect(cspHeader).toContain("img-src http: https: data: blob:") + expect(cspHeader).toContain("manifest-src 'self'") + expect(cspHeader).toContain("media-src 'self'") + expect(cspHeader).toContain("worker-src blob:") + }) + + it("should handle errors and log an error message", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation() + const error = new Error("Test error") + // @ts-ignore + crypto.randomBytes.mockImplementation(() => { + throw error + }) + + await contentSecurityPolicy(ctx, next) + + expect(consoleSpy).toHaveBeenCalledWith( + `Error occurred in Content-Security-Policy middleware: ${error}` + ) + expect(next).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) +}) diff --git a/packages/backend-core/src/security/tests/encryption.spec.ts b/packages/backend-core/src/security/tests/encryption.spec.ts index 0b7eb96b68..8e0af846bd 100644 --- a/packages/backend-core/src/security/tests/encryption.spec.ts +++ b/packages/backend-core/src/security/tests/encryption.spec.ts @@ -4,7 +4,7 @@ import env from "../../environment" describe("encryption", () => { it("should throw an error if API encryption key is not set", () => { const jwt = getSecret(SecretOption.API) - expect(jwt).toBe(env.JWT_SECRET) + expect(jwt).toBe(env.JWT_SECRET?.export().toString()) }) it("should throw an error if encryption key is not set", () => { diff --git a/packages/bbui/package.json b/packages/bbui/package.json index 0830f8ab6f..aeb7418526 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -81,6 +81,7 @@ "@spectrum-css/typography": "3.0.1", "@spectrum-css/underlay": "2.0.9", "@spectrum-css/vars": "3.0.1", + "atrament": "^4.3.0", "dayjs": "^1.10.8", "easymde": "^2.16.1", "svelte-dnd-action": "^0.9.8", diff --git a/packages/bbui/src/Form/Core/Dropzone.svelte b/packages/bbui/src/Form/Core/Dropzone.svelte index 2922d88e7a..26f1dc86c6 100644 --- a/packages/bbui/src/Form/Core/Dropzone.svelte +++ b/packages/bbui/src/Form/Core/Dropzone.svelte @@ -8,6 +8,7 @@ import Link from "../../Link/Link.svelte" import Tag from "../../Tags/Tag.svelte" import Tags from "../../Tags/Tags.svelte" + import ProgressCircle from "../../ProgressCircle/ProgressCircle.svelte" const BYTES_IN_KB = 1000 const BYTES_IN_MB = 1000000 @@ -39,12 +40,14 @@ "jfif", "webp", ] - const fieldId = id || uuid() + let selectedImageIdx = 0 let fileDragged = false let selectedUrl let fileInput + let loading = false + $: selectedImage = value?.[selectedImageIdx] ?? null $: fileCount = value?.length ?? 0 $: isImage = @@ -86,10 +89,15 @@ } if (processFiles) { - const processedFiles = await processFiles(fileList) - const newValue = [...value, ...processedFiles] - dispatch("change", newValue) - selectedImageIdx = newValue.length - 1 + loading = true + try { + const processedFiles = await processFiles(fileList) + const newValue = [...value, ...processedFiles] + dispatch("change", newValue) + selectedImageIdx = newValue.length - 1 + } finally { + loading = false + } } else { dispatch("change", fileList) } @@ -227,7 +235,7 @@ {#if showDropzone}
+ + {#if loading} +
+ +
+ {/if}
{/if} @@ -464,6 +478,7 @@ .spectrum-Dropzone { height: 220px; + position: relative; } .compact .spectrum-Dropzone { height: 40px; @@ -488,4 +503,14 @@ .tag { margin-top: 8px; } + + .loading { + position: absolute; + display: grid; + place-items: center; + height: 100%; + width: 100%; + top: 0; + left: 0; + } diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte index f134c787ca..5ec66870a8 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte @@ -1,4 +1,5 @@ @@ -118,11 +120,11 @@

{/if} - {#if appMigrating} - {/if} @@ -135,7 +137,7 @@ {/each} {/if} - diff --git a/packages/server/src/api/controllers/static/templates/preview.hbs b/packages/server/src/api/controllers/static/templates/preview.hbs index 54b5b1a4e4..87b9ad6ea3 100644 --- a/packages/server/src/api/controllers/static/templates/preview.hbs +++ b/packages/server/src/api/controllers/static/templates/preview.hbs @@ -31,7 +31,7 @@ } -