Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
node_modules
dist
.git
.github
.husky
.env
.env.*
!.env.example
playwright-report
test-results
coverage
*.md
!README.md
.vscode
.idea
.cursor
*.log
temp
tmp
.tmp
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@
# App Configuration
# =================

# Full URL where the app is served, including any path prefix — this is
# the only var needed to configure where the app is mounted. Everything
# else (Vite's asset base, the router, Docker's nginx rewrite, and the
# GitHub Pages 404 redirect) derives from it automatically.
# Examples: http://localhost:9000 | https://example.com/subpath
VITE_BASE_URL=
VITE_OPENEO_API_URL=https://api.explorer.eopf.copernicus.eu/openeo
VITE_APP_TITLE=openEO Studio
VITE_APP_DESCRIPTION=Interactive code editor for openEO satellite imagery processing

Expand Down
70 changes: 70 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Docker

on:
pull_request:
paths:
- 'Dockerfile'
- '.dockerignore'
- 'docker/**'
- '.github/workflows/docker.yml'
push:
branches:
- main
paths:
- 'Dockerfile'
- '.dockerignore'
- 'docker/**'
- '.github/workflows/docker.yml'
release:
types: [published]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout
uses: actions/checkout@v6

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
48 changes: 48 additions & 0 deletions 404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Redirecting...</title>
<script>
// GitHub Pages SPA redirect.
// Encodes the current path into a query param and redirects to the
// app's mount point, which decodes it (see index.html) and calls
// history.replaceState. The mount point is parsed from VITE_BASE_URL
// (baked in at build time by Vite) so it always matches wherever the
// app is actually deployed — Docker/nginx never serves this file,
// since it has real server-side rewrites instead.
(function () {
var prefix = (function () {
try {
return new URL('%VITE_BASE_URL%').pathname.replace(/\/+$/, '');
} catch (e) {
return '';
}
})();
var l = window.location;
if (
prefix &&
l.pathname.indexOf(prefix + '/') !== 0 &&
l.pathname !== prefix
) {
l.replace(l.protocol + '//' + l.host + '/');
return;
}
var rest = l.pathname.slice(prefix.length + 1);
l.replace(
l.protocol +
'//' +
l.host +
prefix +
'/?/' +
rest.replace(/&/g, '~and~') +
(l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
l.hash
);
})();
</script>
</head>
<body>
<p>Redirecting...</p>
</body>
</html>
39 changes: 39 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# syntax=docker/dockerfile:1

FROM node:22-alpine AS build

WORKDIR /app

RUN corepack enable

COPY package.json pnpm-lock.yaml ./
ENV HUSKY=0
RUN pnpm install --frozen-lockfile

COPY . .

# File copy (not ENV) so Docker does not expand the $ placeholders.
COPY docker/vite-placeholders.env .env.production.local

RUN pnpm build

FROM nginx:stable-alpine

COPY --from=build /app/dist /usr/share/nginx/html

COPY docker/default.conf.template /etc/nginx/templates/default.conf.template
COPY docker/90-app-config.sh docker/lib.sh /docker-entrypoint.d/
RUN chmod +x /docker-entrypoint.d/90-app-config.sh \
&& touch /etc/nginx/conf.d/path-prefix.inc

ENV OPENEO_API_URL=https://api.explorer.eopf.copernicus.eu/openeo \

Check warning on line 29 in Dockerfile

View workflow job for this annotation

GitHub Actions / build

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "AUTH_REDIRECT_URI") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 29 in Dockerfile

View workflow job for this annotation

GitHub Actions / build

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "AUTH_CLIENT_ID") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 29 in Dockerfile

View workflow job for this annotation

GitHub Actions / build

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "AUTH_AUTHORITY") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 29 in Dockerfile

View workflow job for this annotation

GitHub Actions / build

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "MAPTILER_KEY") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
BASE_URL= \
APP_TITLE="openEO Studio" \
APP_DESCRIPTION="Interactive code editor for openEO satellite imagery processing" \
MAPTILER_KEY= \
AUTH_AUTHORITY= \
AUTH_CLIENT_ID= \
AUTH_REDIRECT_URI= \
ENABLE_NARRATIVE_EXPORT=false

EXPOSE 80
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ To configure the application:
2. Modify the `.env` file with your specific configuration values
3. Never modify `.env.example` directly as it serves as documentation

#### Where the app is mounted: `VITE_BASE_URL`

`VITE_BASE_URL` is the one variable that controls where the app is served from — set it once to the full URL (including any path prefix) and everything else follows automatically: Vite's built asset paths, the client-side router's mount point, Docker's nginx rewrite rules, and the GitHub Pages 404 redirect.

- **Local dev** (`pnpm dev`): leave it as the dev server's own URL, e.g. `http://localhost:9000` (no path prefix).
- **`pnpm build` + serving locally under a prefix**: set it to the full URL you'll serve from, e.g. `http://localhost:8888/subpath`, then serve `dist/` so it's reachable at that path (e.g. behind a reverse proxy). The build's asset URLs and router will match automatically.
- **GitHub Pages**: set the `VITE_BASE_URL` repository variable to the site's full deployed URL, e.g. `https://developmentseed.github.io/openeo-studio` for a project page, or your custom domain root if one is configured.
- **Docker**: set the `BASE_URL` environment variable at `docker run` time (see below) — no rebuild needed to change it.

### Starting the app

```sh
Expand All @@ -78,7 +87,13 @@ or
pnpm stage
```

This will package the app and place all the contents in the `dist` directory.
The app can then be run by any web server.
This will package the app and place all the contents in the `dist` directory, with asset URLs already baked to match whatever `VITE_BASE_URL` was set to at build time. The app can then be run by any web server capable of serving a single-page app (falling back to `index.html` for unknown paths).

### Docker

```sh
docker build -t openeo-studio .
docker run -p 8888:80 -e BASE_URL=http://localhost:8888/subpath openeo-studio
```

**When building the site for deployment provide the base url trough the `VITE_BASE_URL` environment variable. Omit the leading slash. (E.g. <https://example.com>)**
The image is built once with a relative asset base and configured per-container via environment variables (`BASE_URL`, `OPENEO_API_URL`, `APP_TITLE`, etc. — see `Dockerfile` for the full list). The entrypoint script derives the mount path from `BASE_URL` and writes it into nginx's rewrite rules, the page's `<base>` tag, and `window.__APP_CONFIG__` at container start — so the same image can be redeployed under a different `BASE_URL` without rebuilding.
21 changes: 21 additions & 0 deletions app/config/baseUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Parses VITE_BASE_URL / BASE_URL — the single source of truth for where
* the app is mounted — into a URL, or undefined if it isn't a real,
* parseable absolute URL yet. During Docker's image build, VITE_BASE_URL is
* deliberately left as the literal escaped string `${BASE_URL}` (see
* docker/vite-placeholders.env) to be resolved by envsubst at container
* start; callers must treat that case the same as "not set yet".
*/
export function parseBaseUrl(rawUrl: string | undefined): URL | undefined {
if (!rawUrl) return undefined;
try {
return new URL(rawUrl);
} catch {
return undefined;
}
}

/** The URL's path only, no trailing slash; '' if there is no path or no valid URL. */
export function pathPrefixFromUrl(rawUrl: string | undefined): string {
return parseBaseUrl(rawUrl)?.pathname.replace(/\/+$/, '') ?? '';
}
14 changes: 6 additions & 8 deletions app/config/constants.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
/**
* Application-wide constants from environment variables
*/
export const APP_TITLE = import.meta.env.VITE_APP_TITLE;
export const APP_DESCRIPTION = import.meta.env.VITE_APP_DESCRIPTION;
import { appConfig } from './runtime';

export const MAPTILER_KEY = import.meta.env.VITE_MAPTILER_KEY;
export const APP_TITLE = appConfig.appTitle;
export const APP_DESCRIPTION = appConfig.appDescription;

export const ENABLE_NARRATIVE_EXPORT =
import.meta.env.VITE_ENABLE_NARRATIVE_EXPORT === 'true';
export const MAPTILER_KEY = appConfig.maptilerKey;

export const ENABLE_NARRATIVE_EXPORT = appConfig.enableNarrativeExport;
68 changes: 68 additions & 0 deletions app/config/runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Runtime config: window.__APP_CONFIG__ (Docker) with VITE_* fallbacks (local).
*/

import { pathPrefixFromUrl } from '$config/baseUrl';

export interface AppConfig {
openeoApiUrl: string;
pathPrefix: string;
appTitle: string;
appDescription: string;
maptilerKey: string;
authAuthority: string;
authClientId: string;
authRedirectUri: string;
enableNarrativeExport: boolean;
}

declare global {
interface Window {
__APP_CONFIG__?: Partial<AppConfig>;
}
}

function pick(
runtimeValue: string | undefined,
envValue: string | undefined,
fallback = ''
): string {
return runtimeValue || envValue || fallback;
}

/** Leading slash, no trailing slash; empty when served at root. */
function normalizePathPrefix(prefix: string): string {
if (!prefix || prefix === '/') return '';
return `/${prefix.replace(/^\/+|\/+$/g, '')}`;
}

const runtime = window.__APP_CONFIG__ ?? {};

export const appConfig: AppConfig = {
openeoApiUrl: pick(
runtime.openeoApiUrl,
import.meta.env.VITE_OPENEO_API_URL,
'https://api.explorer.eopf.copernicus.eu/openeo'
),
pathPrefix: normalizePathPrefix(
pick(runtime.pathPrefix, pathPrefixFromUrl(import.meta.env.VITE_BASE_URL))
),
appTitle: pick(runtime.appTitle, import.meta.env.VITE_APP_TITLE),
appDescription: pick(
runtime.appDescription,
import.meta.env.VITE_APP_DESCRIPTION
),
maptilerKey: pick(runtime.maptilerKey, import.meta.env.VITE_MAPTILER_KEY),
authAuthority: pick(
runtime.authAuthority,
import.meta.env.VITE_AUTH_AUTHORITY
),
authClientId: pick(runtime.authClientId, import.meta.env.VITE_AUTH_CLIENT_ID),
authRedirectUri: pick(
runtime.authRedirectUri,
import.meta.env.VITE_AUTH_REDIRECT_URI
),
enableNarrativeExport:
runtime.enableNarrativeExport ??
import.meta.env.VITE_ENABLE_NARRATIVE_EXPORT === 'true'
};
15 changes: 6 additions & 9 deletions app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { StacApiProvider } from '@developmentseed/stac-react';
import { BrowserRouter } from 'react-router';
import { WebStorageStateStore } from 'oidc-client-ts';

import { appConfig } from '$config/runtime';
import { PyodideProvider } from '$contexts/pyodide-context';
import { AuthMonitor } from '$utils/auth-monitor';
import { setupReloadDetector } from './utils/reload-detector';
Expand All @@ -21,15 +22,11 @@ if (import.meta.env.DEV) {
setupReloadDetector();
}

const authAuthority = import.meta.env.VITE_AUTH_AUTHORITY || '';
const authClientId = import.meta.env.VITE_AUTH_CLIENT_ID || '';
const authRedirectUri = import.meta.env.VITE_AUTH_REDIRECT_URI || '';

const oidcConfig: AuthProviderProps = {
userStore: new WebStorageStateStore({ store: window.localStorage }),
authority: authAuthority,
client_id: authClientId,
redirect_uri: authRedirectUri,
authority: appConfig.authAuthority,
client_id: appConfig.authClientId,
redirect_uri: appConfig.authRedirectUri,
onSigninCallback: (user) => {
// eslint-disable-next-line no-console
console.log('[AUTH] onSigninCallback triggered', {
Expand Down Expand Up @@ -91,11 +88,11 @@ function Root() {
const authProps = window.__MOCK_AUTH__ ? {} : oidcConfig;

return (
<BrowserRouter>
<BrowserRouter basename={appConfig.pathPrefix || undefined}>
<AuthWrapper {...authProps}>
{!window.__MOCK_AUTH__ && <AuthMonitor />}
<ChakraProvider value={system}>
<StacApiProvider apiUrl='https://api.explorer.eopf.copernicus.eu/openeo'>
<StacApiProvider apiUrl={appConfig.openeoApiUrl}>
<PyodideProvider>
<App />
</PyodideProvider>
Expand Down
3 changes: 2 additions & 1 deletion app/utils/code-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type BackendService
} from '$types';

import { appConfig } from '$config/runtime';
import { getInstanceId } from './instance-id';

// Track active services for cleanup
Expand All @@ -18,7 +19,7 @@ const activeServices: ServiceInfo[] = [];
export const EXAMPLE_CODE = trueColorAlgorithm;

// OpenEO API constants
const OPENEO_API_URL = 'https://api.explorer.eopf.copernicus.eu/openeo';
const OPENEO_API_URL = appConfig.openeoApiUrl;
const AUTH_PREFIX = 'Bearer oidc/oidc/';

// Service title conventions for backend-side discovery
Expand Down
Loading
Loading