diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d144fdd --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# Settings for docker-compose.yml — every service reads this one file. +# +# cp .env.example .env +# +# Note what is NOT here: PORT, LISTEN_ADDR and BASE_PATH. Each example already defaults to its +# own port and its own prefix, and all nine share one environment here — a PORT in this file +# would be handed to all of them at once. Leave them out and the defaults do the right thing. +# +# This is not one of the per-app .env.example files. Do not copy one of those here. + +# The port nginx publishes, and the origin the payer's browser reaches the examples at — nginx, +# not any app's own port. Each example appends its own BASE_PATH to it to build the 3DS return +# URL, so the two lines must name the same port. Change both if 8080 is already taken. +HTTP_PORT=8080 +PUBLIC_URL=http://localhost:8080 + +# Both are given to you with your credentials. API_URL is the gateway root, SDK_URL must be on +# that same host: the SDK refuses to run when it is served from the merchant origin. +# API_URL=https:///paynet +# SDK_URL=https:///assets/libs/hosted-fields/latest/index.js?segment=paynet +API_URL= +SDK_URL= + +# Credentials provided by the gateway on onboarding. Seven of the nine examples refuse to start +# without them, which is deliberate — an example that served a payment page it cannot take a +# payment with would be worse. +ENDPOINT_ID= +MERCHANT_LOGIN= +# Shared secret the gateway signs its callbacks with, checked on /result +MERCHANT_CONTROL= + +# The RSA private key is a file, not a variable: put it at ./private_key.pem and compose mounts +# it read-only into every service at the path below. *.pem is git-ignored repository wide. +# +# It must be PKCS#8 — the JDK reads nothing else: +# openssl pkcs8 -topk8 -nocrypt -in your_key.pem -out private_key.pem +PRIVATE_KEY_PATH=/run/secrets/private_key.pem + +# Demo order +ORDER_AMOUNT=1.00 +ORDER_CURRENCY=USD diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3765d1d..c55a66f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,16 @@ jobs: # other three, on a tag, each on the runner native to it - run: cargo build --release + # And once more on the version Cargo.toml declares, because every step above runs on + # whatever `stable` is today. A language feature stabilised after 1.85 passes all of them + # and then fails for anyone who took "Rust 1.85 or newer" at its word — which is exactly + # what a let chain in src/main.rs did until it was found by building the example in a + # pinned container rather than on a runner. + - uses: dtolnay/rust-toolchain@master + with: + toolchain: '1.85' + - run: cargo +1.85 build --release + dotnet: name: dotnet-aspnetcore-js runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 6400f74..e4a5721 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -325,6 +325,40 @@ Five things about it are load-bearing: Running it rebuilds `nextjs/.next`, because `basePath` is baked in at build time. +## All nine at once, in `docker-compose.yml` + +`docker compose up --build` builds nine images and runs them behind one nginx on `:8080`. It +exists for two reasons, and the second is the one to protect: + +- no toolchain to install, which is what `e2e-tests/` still needs nine of; +- **it is the only thing that ever executes the nine `deploy/nginx.conf`.** Those files ship in + the release archives and the READMEs tell people to install them, and until this they were + documentation with nothing to check them. + +That second reason is what fixes the shape of the file. Every app service joins the nginx +container's network namespace (`network_mode: "service:nginx"`), so the snippets' own +`proxy_pass http://127.0.0.1:300x` is true inside it and they are mounted **unmodified**. Service +names and `LISTEN_ADDR=0.0.0.0` would have been the ordinary answer and would have meant nine +second copies, drifting silently — the one thing `shared/` exists to prevent. Keep the mounts +read-only and keep them pointing at `*/deploy/nginx.conf`. + +Three consequences worth knowing before editing it: a service in a shared namespace may not +declare `ports`, `networks` or `hostname`; **`docker compose restart` does not work** — the nine +hold a handle on the namespace nginx owns, so restarting leaves some of them without a network and +`up -d --force-recreate` is the way; and the root `.env` must carry **no `PORT`, `LISTEN_ADDR` or +`BASE_PATH`** — all nine read that one file, and every example already defaults to its own port +and prefix. + +`php-js` is the only app whose shipped deploy config cannot be mounted as it is: its pool sets +`clear_env = yes` and names every setting as an `env[]` line, which is right for a system FPM and +blind to compose's environment. Its `Dockerfile` writes a compose pool instead, and says in a +comment exactly which two lines differ and why. `nextjs` is the only one needing a build argument, +because `next build` bakes `basePath` in. + +**This is what a ninth language now costs**, on top of the one line in `scripts/sync-shared.sh`: +a `Dockerfile`, a `.dockerignore`, a service in `docker-compose.yml` and a mount line for its +snippet. Images are not built in CI. + ## Documentation Link to `doc.payneteasy.com`, never to internal or staging hosts. The integration reference is diff --git a/README.md b/README.md index 91e8a0d..55ccf7a 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,27 @@ rather than falling back to a host baked in at some point and forgotten. Go, Exp Sinatra, Spring Boot, axum and ASP.NET Core check at startup; Next checks on the first request, so that a build needs no credentials, and PHP on every request, because it has no startup to check at. -## Run +## Run all nine at once + +That "behind one nginx" is not a figure of speech, and `docker-compose.yml` is it: nine images, +one nginx, no toolchain to install. + +```bash +cp .env.example .env # the gateway URLs and your credentials +cp your_key.pem private_key.pem # PKCS#8 — the JDK reads nothing else +docker compose up --build # http://localhost:8080/ +``` + +The front page lists all nine. Each is routed by **its own [`deploy/nginx.conf`](go-js/deploy/nginx.conf)**, +mounted unmodified — so this is also what checks that the file in every release archive is +correct, which nothing else does. + +It is a demo and not a deployment: plain HTTP on a local port. The first build is slow — it +compiles Rust, packages a Spring Boot jar and runs `next build`. Set `HTTP_PORT` in `.env` if +something already has 8080, and restart the stack rather than one service — every app shares the +nginx container's network namespace, which is what lets the shipped snippets be used unchanged. + +## Run one on its own They all mount everything under a URL prefix, so they can sit behind one nginx at once, and they all listen on `127.0.0.1` by default — they speak plain HTTP and trust `X-Forwarded-For`, so a @@ -212,6 +232,8 @@ Each app has its own README with the details — settings, the 3DS return, deplo ``` shared/ the browser half, once scripts/sync-shared.sh copies it into every app +docker-compose.yml all nine at once, behind one nginx +docker/nginx/ the server block the nine shipped snippets are included into go-js/ Go + plain JS, assets embedded in the binary nodejs-express-js/ Node.js + Express + plain JS php-js/ PHP + plain JS, no Composer diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f7b29f5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,124 @@ +# Every example, built and run at once behind one nginx on http://localhost:8080/ +# +# cp .env.example .env # the gateway URLs and your credentials +# cp private_key.pem +# docker compose up --build +# +# Two things about the shape of this file are deliberate. +# +# 1. Every app service joins the nginx container's network namespace. That is what lets each +# app's own deploy/nginx.conf — the file it ships and its README tells you to install — be +# mounted here unchanged: those say `proxy_pass http://127.0.0.1:300x`, and inside one shared +# namespace that is exactly where the app is. The alternative, service names and +# LISTEN_ADDR=0.0.0.0, would mean nine second copies of those files, drifting quietly. +# +# It also means the apps keep their shipped LISTEN_ADDR=127.0.0.1 default, that nginx needs no +# DNS and so starts before the apps do, and that ports 3000-3008 stay free on your machine. +# +# The price, and it is worth knowing before you hit it: a service in a shared namespace may +# not declare `ports`, `networks` or `hostname`, and the nine hold a handle on the namespace +# nginx owns — so `docker compose restart` leaves some of them without a network. Restart the +# stack, never one service: +# +# docker compose up -d --force-recreate # or: down, then up -d +# +# 2. One .env and one private_key.pem for all nine. PUBLIC_URL is the nginx origin rather than +# any app's own port, because it is what builds the 3DS return URL the payer's browser is sent +# to — each app appends its own BASE_PATH to it. +# +# This is a demo, not a deployment. It speaks plain HTTP and publishes nginx on a local port. + +# What every app service shares. Spelled once: nine copies of the same five lines is nine places +# for one of them to quietly differ. +x-app: &app + env_file: .env + volumes: [./private_key.pem:/run/secrets/private_key.pem:ro] + # See (1) above — this is what lets the shipped deploy/nginx.conf files be mounted unchanged + network_mode: "service:nginx" + depends_on: [nginx] + +services: + nginx: + image: nginx:1.27-alpine + ports: + # HTTP_PORT in .env if something already has 8080. PUBLIC_URL must name the same port: + # it is the origin the payer's browser is sent back to after a 3DS challenge. + - "${HTTP_PORT:-8080}:8080" + volumes: + - ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./docker/nginx/index.html:/usr/share/nginx/html/index.html:ro + + # The nine shipped snippets, unmodified. A diff here is a real bug in a release archive. + - ./go-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/go.conf:ro + - ./nodejs-express-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/nodejs-express-js.conf:ro + - ./php-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/php.conf:ro + - ./python-flask-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/python.conf:ro + - ./ruby-sinatra-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/ruby.conf:ro + - ./java-springboot-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/java.conf:ro + - ./rust-axum-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/rust.conf:ro + - ./dotnet-aspnetcore-js/deploy/nginx.conf:/etc/nginx/conf.d/apps/dotnet.conf:ro + - ./nextjs/deploy/nginx.conf:/etc/nginx/conf.d/apps/nextjs.conf:ro + + # Three of those snippets carry an optional block serving the four client assets straight + # off disk. The directories are committed copies of shared/, so mounting them adds no + # second source of truth — and it means that block is exercised rather than assumed. + - ./nodejs-express-js/public:/opt/hosted-fields-examples-nodejs-express-js/public:ro + - ./python-flask-js/public:/opt/hosted-fields-examples-python/public:ro + - ./ruby-sinatra-js/public:/opt/hosted-fields-examples-ruby/public:ro + + # The PHP snippet talks to php-fpm over a unix socket rather than a port + - php-socket:/run/php + + go: + <<: *app + build: ./go-js + + nodejs-express-js: + <<: *app + build: ./nodejs-express-js + + php: + <<: *app + build: ./php-js + # The socket deploy/nginx.conf's fastcgi_pass names, shared with the nginx container + volumes: + - ./private_key.pem:/run/secrets/private_key.pem:ro + - php-socket:/run/php + + python: + <<: *app + build: ./python-flask-js + + ruby: + <<: *app + build: ./ruby-sinatra-js + + java: + <<: *app + build: ./java-springboot-js + + rust: + <<: *app + build: ./rust-axum-js + + dotnet: + <<: *app + build: ./dotnet-aspnetcore-js + + nextjs: + <<: *app + build: + context: ./nextjs + # basePath is baked in by `next build`, so it has to be known here as well as at runtime. + # Change it and you have to rebuild this one image; the other eight read BASE_PATH at + # startup. + args: + BASE_PATH: /hosted-fields-examples-nextjs + environment: + # Next reads the interface under this name, not LISTEN_ADDR + HOSTNAME: 127.0.0.1 + PORT: "3002" + BASE_PATH: /hosted-fields-examples-nextjs + +volumes: + php-socket: diff --git a/docker/nginx/index.html b/docker/nginx/index.html new file mode 100644 index 0000000..37d22b8 --- /dev/null +++ b/docker/nginx/index.html @@ -0,0 +1,40 @@ + + + + + + + Hosted Fields examples + + + +

Hosted Fields examples

+

The same payment, one server per language, all behind this nginx.

+ + + diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..579b4cd --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,26 @@ +# The one server block every example shares, for docker-compose.yml at the repository root. +# +# It deliberately contains no routing of its own. Each app's own deploy/nginx.conf — the file +# that ships in its release archive and that its README tells you to install — is mounted into +# conf.d/apps/ and included here unchanged. That is the point of this setup as much as the +# convenience is: those nine files are otherwise documentation with nothing to check them. +# +# They say `proxy_pass http://127.0.0.1:300x`, and they work here verbatim because every app +# container shares this container's network namespace. See the comment in docker-compose.yml. + +server { + listen 8080; + server_name _; + + # The nine prefixes, byte for byte as each example ships them + include /etc/nginx/conf.d/apps/*.conf; + + # A front door for the demo. Not part of any example: the payment pages all live under a + # prefix, and nothing in shared/ is served from the root. + # try_files rather than index: `index` fires an internal redirect to /index.html, and this + # server has no location that would match it. + location = / { + root /usr/share/nginx/html; + try_files /index.html =404; + } +} diff --git a/dotnet-aspnetcore-js/.dockerignore b/dotnet-aspnetcore-js/.dockerignore new file mode 100644 index 0000000..ea1b2e1 --- /dev/null +++ b/dotnet-aspnetcore-js/.dockerignore @@ -0,0 +1,9 @@ +# What must never reach an image. .env, *.pem and *.key are git-ignored repository wide — +# an image is one more place a key does not belong. +.env +*.pem +*.key + +# .NET build output +bin/ +obj/ diff --git a/dotnet-aspnetcore-js/CLAUDE.md b/dotnet-aspnetcore-js/CLAUDE.md index 65a84e8..0631adf 100644 --- a/dotnet-aspnetcore-js/CLAUDE.md +++ b/dotnet-aspnetcore-js/CLAUDE.md @@ -35,6 +35,11 @@ bare, and the test project is named explicitly wherever it is needed. `{prefix}/`, which is the redirect Go's mux, Tomcat and nginx all send by themselves. It is registered before anything else, so it short-circuits whatever routing has already selected. The payment page itself is registered at the bare prefix and reached at the trailing-slash form. +- **`MapGet` maps GET and nothing else**, so a `HEAD` would be answered `405` — this example + alone, since Go's `ServeMux`, Express and the rest all serve `HEAD` from their `GET` route. + Every GET route here goes through the one-line `Get` helper in `Program.cs`, which is + `MapMethods(…, ["GET", "HEAD"], …)`. Kestrel drops the body of a HEAD response itself, so the + handlers know nothing about it. - **No `UseStaticFiles`, no `wwwroot`, no static web assets.** `public/` goes out through the four-name allowlist in `Program.cs` and nowhere else. Left to itself the framework would serve the client scripts a second way, at the root rather than under `BASE_PATH` and with caching diff --git a/dotnet-aspnetcore-js/Dockerfile b/dotnet-aspnetcore-js/Dockerfile new file mode 100644 index 0000000..b9dc06d --- /dev/null +++ b/dotnet-aspnetcore-js/Dockerfile @@ -0,0 +1,22 @@ +# The ASP.NET Core example, for docker-compose.yml at the repository root. +# +# Two stages: views/ and public/ are embedded resources, so the runtime image carries the +# published assembly and nothing beside it. + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1 +WORKDIR /src +COPY . . +# The app itself references no package; only the test project does, and it is not published +RUN dotnet publish HostedFields.csproj -c Release -o /out + +# aspnet, not runtime: the publish is framework-dependent and this is an ASP.NET Core app +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /opt/hosted-fields-examples-dotnet +COPY --from=build /out . +# `dotnet `, because UseAppHost is off — the archive is meant to be platform-neutral, so +# no native launcher is built for whichever machine happened to publish it. +# +# The image sets ASPNETCORE_HTTP_PORTS=8080, which has no effect: Program.cs calls UseUrls() +# explicitly from PORT and LISTEN_ADDR, and that wins. +CMD ["dotnet", "hosted-fields-example-dotnet.dll"] diff --git a/dotnet-aspnetcore-js/Program.cs b/dotnet-aspnetcore-js/Program.cs index ac90873..1eca31d 100644 --- a/dotnet-aspnetcore-js/Program.cs +++ b/dotnet-aspnetcore-js/Program.cs @@ -86,18 +86,23 @@ // The payment page is registered at the bare prefix and reached at the trailing-slash form: the // matcher ignores a trailing slash on the request path, and the middleware above has already sent // anyone who asked for the bare form to `{prefix}/`. -app.MapGet(basePath, Checkout); -app.MapGet(basePath + "/config.js", ConfigJs); -app.MapGet(basePath + "/result-config.js", ResultConfigJs); +// +// MapMethods rather than MapGet throughout: minimal APIs map exactly the verb named, and a GET +// route that answers 405 to HEAD would be this example alone — Go's ServeMux, Express and the +// rest all serve HEAD from their GET route, and a health check that uses it should not have to +// know which language is behind the prefix. +Get(basePath, Checkout); +Get(basePath + "/config.js", ConfigJs); +Get(basePath + "/result-config.js", ResultConfigJs); app.MapPost(basePath + "/pay", Pay); -app.MapGet(basePath + "/status", Status); +Get(basePath + "/status", Status); // The gateway returns the payer from a 3DS challenge with a POST, not a GET, so the callback and // the page it sends them to are separate routes. app.MapMethods(basePath + "/result/callback", ["GET", "POST"], ResultCallback); -app.MapGet(basePath + "/result", Result); +Get(basePath + "/result", Result); // Stylesheet and client scripts. A literal segment beats a parameter in ASP.NET Core's route // matching, so the routes above are not shadowed by this one. -app.MapGet(basePath + "/{file}", PublicFile); +Get(basePath + "/{file}", PublicFile); log.LogInformation("listening on {Url}", settings.LocalUrl); app.Run(); @@ -105,6 +110,9 @@ // returns 1, which makes this entry point an int-returning one. return 0; +// Kestrel drops the body of a HEAD response itself, so the handlers need to know nothing about it +void Get(string pattern, Delegate handler) => app.MapMethods(pattern, ["GET", "HEAD"], handler); + Task Checkout(HttpContext context) => ServeView(context, "checkout.html"); // Step 1. A fresh single-use ticket for every page load, handed to the page as a script. diff --git a/go-js/.dockerignore b/go-js/.dockerignore new file mode 100644 index 0000000..d00dbe3 --- /dev/null +++ b/go-js/.dockerignore @@ -0,0 +1,10 @@ +# What must never reach an image. .env, *.pem and *.key are git-ignored repository wide — +# an image is one more place a key does not belong. +.env +*.pem +*.key + +# Local build output, and the personal deploy script +/hosted-fields-example-go +/hosted-fields-examples-go +/deploy-dev-*.sh diff --git a/go-js/Dockerfile b/go-js/Dockerfile new file mode 100644 index 0000000..220458f --- /dev/null +++ b/go-js/Dockerfile @@ -0,0 +1,22 @@ +# The Go example, for docker-compose.yml at the repository root. +# +# Two stages, because the artefact is one static binary with views/ and public/ inside it +# (//go:embed in main.go) — there is nothing else to carry to the runtime image. + +FROM golang:1.24-bookworm AS build +WORKDIR /src +COPY . . +# The same flags release.yml uses. CGO off so the binary runs on a distro with no toolchain. +RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/hosted-fields-examples-go . + +FROM debian:bookworm-slim +# The app calls the gateway over HTTPS and the slim image ships no trust store +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /opt/hosted-fields-examples-go +COPY --from=build /out/hosted-fields-examples-go . +# PORT, LISTEN_ADDR and the rest arrive as real environment variables from compose. The default +# LISTEN_ADDR is 127.0.0.1 and stays that way: every app shares the nginx container's network +# namespace, so loopback is exactly where nginx looks for it. +CMD ["./hosted-fields-examples-go"] diff --git a/java-springboot-js/.dockerignore b/java-springboot-js/.dockerignore new file mode 100644 index 0000000..6dc2b43 --- /dev/null +++ b/java-springboot-js/.dockerignore @@ -0,0 +1,8 @@ +# What must never reach an image. .env, *.pem and *.key are git-ignored repository wide — +# an image is one more place a key does not belong. +.env +*.pem +*.key + +# Maven build output +target/ diff --git a/java-springboot-js/Dockerfile b/java-springboot-js/Dockerfile new file mode 100644 index 0000000..5f5841a --- /dev/null +++ b/java-springboot-js/Dockerfile @@ -0,0 +1,19 @@ +# The Spring Boot example, for docker-compose.yml at the repository root. +# +# Two stages: the jar carries views/ and public/ as resources, so the runtime image is a JRE +# and one file. + +# maven:…-21 rather than ./mvnw, which downloads Maven itself on first use — a build stage +# should not spend a network round trip on fetching its own build tool. +FROM maven:3.9-eclipse-temurin-21 AS build +WORKDIR /src +COPY . . +# Tests are `./mvnw verify`'s job; this stage only has to produce the jar, as release.yml does +RUN mvn -B -DskipTests package + +FROM eclipse-temurin:21-jre +WORKDIR /opt/hosted-fields-examples-java +COPY --from=build /src/target/hosted-fields-example-java.jar . +# BASE_PATH becomes the servlet context path in main(), before the container is built, so it is +# an ordinary environment variable here like PORT and LISTEN_ADDR. +CMD ["java", "-jar", "hosted-fields-example-java.jar"] diff --git a/nextjs/.dockerignore b/nextjs/.dockerignore new file mode 100644 index 0000000..750387a --- /dev/null +++ b/nextjs/.dockerignore @@ -0,0 +1,9 @@ +# What must never reach an image. .env, *.pem and *.key are git-ignored repository wide — +# an image is one more place a key does not belong. +.env +*.pem +*.key + +# Installed and built in the image, not copied into it +node_modules/ +.next/ diff --git a/nextjs/Dockerfile b/nextjs/Dockerfile new file mode 100644 index 0000000..913feec --- /dev/null +++ b/nextjs/Dockerfile @@ -0,0 +1,27 @@ +# The Next.js example, for docker-compose.yml at the repository root. +# +# Two stages: `output: 'standalone'` gives one server.js plus a minimal node_modules, so the +# runtime image carries no yarn install. + +FROM node:20 AS build +WORKDIR /src +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile +COPY . . +# basePath is resolved by `next build` and baked into the output, unlike every other example +# where BASE_PATH is read at startup. It therefore has to be a build argument — and compose +# passes the same value again as a runtime variable, because src/shared/config/env.ts reads it +# independently to build the 3DS redirect_url. The two must agree. +ARG BASE_PATH=/hosted-fields-examples-nextjs +ENV BASE_PATH=$BASE_PATH +RUN yarn build + +FROM node:20-slim +WORKDIR /opt/hosted-fields-examples-nextjs +# The three pieces a standalone build is assembled from, exactly as release.yml assembles them +COPY --from=build /src/.next/standalone/. ./ +COPY --from=build /src/.next/static ./.next/static +COPY --from=build /src/public ./public +# HOSTNAME, not LISTEN_ADDR: the standalone server binds every interface unless it is told +# otherwise, and compose sets it to 127.0.0.1 the way the systemd unit does. +CMD ["node", "server.js"] diff --git a/nextjs/deploy/nginx.conf b/nextjs/deploy/nginx.conf index d7d8a5d..bd3bb54 100644 --- a/nextjs/deploy/nginx.conf +++ b/nextjs/deploy/nginx.conf @@ -1,13 +1,16 @@ # One location per example, so several of them share a single server block. # Include from your server { } — the prefix must match BASE_PATH. -location = /hosted-fields-examples-nextjs { - # Relative Location, so the redirect survives a non-default port or another proxy in front - absolute_redirect off; - return 301 /hosted-fields-examples-nextjs/; -} - -location /hosted-fields-examples-nextjs/ { +# This is the one example without a `location = {prefix}` redirect to the trailing-slash form, +# because Next normalises the other way. The eight plain-JS examples serve pages whose asset URLs +# are relative, so `{prefix}/` is the form that has to be reached; Next emits absolute URLs with +# the basePath already in them and, with `trailingSlash` at its default of false, answers +# `{prefix}/` with a 308 to `{prefix}`. A redirect to the slash form here would bounce against +# that one forever. +# +# So the prefix is matched without the trailing slash, which covers the bare form and everything +# under it, and Next is left to normalise. +location /hosted-fields-examples-nextjs { # No trailing slash and no path: the prefix must reach the app, it routes on it proxy_pass http://127.0.0.1:3002; diff --git a/nodejs-express-js/.dockerignore b/nodejs-express-js/.dockerignore new file mode 100644 index 0000000..c374c67 --- /dev/null +++ b/nodejs-express-js/.dockerignore @@ -0,0 +1,9 @@ +# What must never reach an image. .env, *.pem and *.key are git-ignored repository wide — +# an image is one more place a key does not belong. +.env +*.pem +*.key + +# Installed and built in the image, not copied into it +node_modules/ +dist/ diff --git a/nodejs-express-js/Dockerfile b/nodejs-express-js/Dockerfile new file mode 100644 index 0000000..59d6605 --- /dev/null +++ b/nodejs-express-js/Dockerfile @@ -0,0 +1,20 @@ +# The Express example, for docker-compose.yml at the repository root. +# +# Two stages: `npm run build` bundles express into dist/server.js and copies views/ and public/ +# beside it, so the runtime image needs no node_modules at all. + +FROM node:20 AS build +WORKDIR /src +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:20-slim +WORKDIR /opt/hosted-fields-examples-nodejs-express-js +COPY --from=build /src/dist/. . +# `node server.js` and never `npm start`, which is `node --env-file=.env …` and hard-fails when +# there is no .env — settings arrive as real environment variables here. +# +# The working directory matters: server.js looks for views/ and public/ beside itself. +CMD ["node", "server.js"] diff --git a/php-js/.dockerignore b/php-js/.dockerignore new file mode 100644 index 0000000..08a8d28 --- /dev/null +++ b/php-js/.dockerignore @@ -0,0 +1,7 @@ +# What must never reach an image. .env, *.pem and *.key are git-ignored repository wide — +# an image is one more place a key does not belong. +.env +*.pem +*.key + +# Nothing is built and nothing is installed: no Composer, no vendor directory. diff --git a/php-js/Dockerfile b/php-js/Dockerfile new file mode 100644 index 0000000..d0656ff --- /dev/null +++ b/php-js/Dockerfile @@ -0,0 +1,66 @@ +# The PHP example, for docker-compose.yml at the repository root. +# +# One stage and no build: there is no Composer and nothing to compile. php:8.4-fpm already has +# curl, openssl and json, which is everything this app asks for. +# +# PHP-FPM rather than `php -S`, because that is what the example deploys as — deploy/nginx.conf +# sends every request under the prefix to index.php over FastCGI, and router.php says in its own +# header that it is not used at all under nginx. Running the built-in server here instead would +# leave the one piece of shipped configuration this whole compose setup exists to exercise +# untested. + +FROM php:8.4-fpm + +# The path is not free to choose: deploy/nginx.conf names it in SCRIPT_FILENAME, and that file +# is mounted into the nginx container unchanged. +WORKDIR /opt/hosted-fields-examples-php +COPY . . + +# The pool. deploy/hosted-fields-examples-php.pool.conf is the one shipped for a system FPM at +# /etc/php/8.4/fpm/pool.d/; this is its compose counterpart at the path the official image uses, +# and it differs in exactly two ways, both forced: +# +# - `clear_env = no`. The shipped pool inherits nothing and names every setting as an env[] +# line, which is right when the pool file is the configuration. Here the settings come from +# compose, and a pool that cleared the environment would not see one of them. +# - `listen.mode = 0666`. The shipped pool scopes the socket to www-data, which works when one +# host user runs both. nginx is a separate image here with its own uid. +# +# Everything else is kept: the socket path, the display_errors flag and the open_basedir the +# shipped pool uses in place of the systemd ProtectSystem= lines the other examples have. +RUN rm -f /usr/local/etc/php-fpm.d/*.conf \ + && printf '%s\n' \ + '[global]' \ + 'daemonize = no' \ + '; php-fpm closes STDOUT on startup, so a log on /proc/self/fd/1 would go nowhere' \ + 'error_log = /proc/self/fd/2' \ + 'log_limit = 8192' \ + '' \ + '[hosted-fields-examples-php]' \ + 'user = www-data' \ + 'group = www-data' \ + '' \ + 'listen = /run/php/hosted-fields-examples-php.sock' \ + 'listen.mode = 0666' \ + '' \ + 'pm = dynamic' \ + 'pm.max_children = 10' \ + 'pm.start_servers = 2' \ + 'pm.min_spare_servers = 1' \ + 'pm.max_spare_servers = 3' \ + '' \ + 'clear_env = no' \ + '' \ + '; So that a PHP error reaches `docker compose logs php` rather than nowhere' \ + 'access.log = /proc/self/fd/2' \ + 'catch_workers_output = yes' \ + 'decorate_workers_output = no' \ + '' \ + 'php_admin_value[open_basedir] = /opt/hosted-fields-examples-php/:/run/secrets/' \ + 'php_admin_flag[display_errors] = off' \ + 'php_admin_flag[log_errors] = on' \ + 'php_admin_value[error_log] = /proc/self/fd/2' \ + > /usr/local/etc/php-fpm.d/hosted-fields.conf + +# The socket lives on a volume shared with the nginx container +RUN mkdir -p /run/php diff --git a/python-flask-js/.dockerignore b/python-flask-js/.dockerignore new file mode 100644 index 0000000..5828a9a --- /dev/null +++ b/python-flask-js/.dockerignore @@ -0,0 +1,9 @@ +# What must never reach an image. .env, *.pem and *.key are git-ignored repository wide — +# an image is one more place a key does not belong. +.env +*.pem +*.key + +# A local venv is built for the wrong platform, and the app writes no bytecode +.venv/ +__pycache__/ diff --git a/python-flask-js/Dockerfile b/python-flask-js/Dockerfile new file mode 100644 index 0000000..f5a452f --- /dev/null +++ b/python-flask-js/Dockerfile @@ -0,0 +1,21 @@ +# The Flask example, for docker-compose.yml at the repository root. +# +# One stage: the toolchain is the runtime. python:3.12-slim rather than Alpine, because +# cryptography ships manylinux wheels and would otherwise be compiled from Rust source. + +FROM python:3.12-slim +# The counterpart of the unit's Environment=PYTHONDONTWRITEBYTECODE=1: the app writes nothing, +# and a __pycache__ beside the sources is the only thing that would contradict that. +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 + +WORKDIR /opt/hosted-fields-examples-python +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . + +# gunicorn, not `python app.py`: that branch is the Werkzeug development server, and the +# Werkzeug debugger behind a payment page is remote code execution. This is the unit's ExecStart. +# +# Shell form on purpose — LISTEN_ADDR and PORT are read by gunicorn's command line here, not by +# the app, and exec form would not expand them. +CMD gunicorn --bind ${LISTEN_ADDR:-127.0.0.1}:${PORT:-3004} --workers 2 app:app diff --git a/ruby-sinatra-js/.dockerignore b/ruby-sinatra-js/.dockerignore new file mode 100644 index 0000000..c6d89dd --- /dev/null +++ b/ruby-sinatra-js/.dockerignore @@ -0,0 +1,9 @@ +# What must never reach an image. .env, *.pem and *.key are git-ignored repository wide — +# an image is one more place a key does not belong. +.env +*.pem +*.key + +# A local bundle carries native extensions built for the wrong platform +.bundle/ +vendor/ diff --git a/ruby-sinatra-js/Dockerfile b/ruby-sinatra-js/Dockerfile new file mode 100644 index 0000000..44135c1 --- /dev/null +++ b/ruby-sinatra-js/Dockerfile @@ -0,0 +1,20 @@ +# The Sinatra example, for docker-compose.yml at the repository root. +# +# One stage, and the full ruby image rather than -slim: puma's nio4r is a native extension and +# needs a compiler, so a slim runtime would have to carry one anyway. + +FROM ruby:3.1 +# BUNDLE_PATH as an environment variable rather than `bundle config`, which would write a +# .bundle/config into the app directory — the same reason the systemd unit sets it this way. +ENV BUNDLE_PATH=/opt/hosted-fields-examples-ruby/vendor/bundle \ + BUNDLE_WITHOUT=development:test + +WORKDIR /opt/hosted-fields-examples-ruby +COPY Gemfile Gemfile.lock ./ +RUN bundle install +COPY . . + +# puma, not `ruby app.rb`: that branch is Sinatra's development server. This is the unit's +# ExecStart, and shell form for the same reason as the Flask image — puma reads the two names +# off its command line. +CMD bundle exec puma --bind tcp://${LISTEN_ADDR:-127.0.0.1}:${PORT:-3005} config.ru diff --git a/rust-axum-js/.dockerignore b/rust-axum-js/.dockerignore new file mode 100644 index 0000000..0b93184 --- /dev/null +++ b/rust-axum-js/.dockerignore @@ -0,0 +1,8 @@ +# What must never reach an image. .env, *.pem and *.key are git-ignored repository wide — +# an image is one more place a key does not belong. +.env +*.pem +*.key + +# Cargo build output — the largest of them all +target/ diff --git a/rust-axum-js/Dockerfile b/rust-axum-js/Dockerfile new file mode 100644 index 0000000..cb7e207 --- /dev/null +++ b/rust-axum-js/Dockerfile @@ -0,0 +1,21 @@ +# The Rust example, for docker-compose.yml at the repository root. +# +# Two stages, because the artefact is one binary with views/ and public/ inside it +# (include_dir! in main.rs) — there is nothing else to carry to the runtime image. + +# The version Cargo.toml declares. It really does build on it — see the comment at the nested +# `if let` in src/main.rs, which is there so that it does. +FROM rust:1.85-bookworm AS build +WORKDIR /src +COPY . . +# views/ and public/ have to be present here: include_dir! reads them at compile time +RUN cargo build --release + +FROM debian:bookworm-slim +# reqwest uses rustls with the system trust store, and the slim image ships none +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /opt/hosted-fields-examples-rust +COPY --from=build /src/target/release/hosted-fields-example-rust . +CMD ["./hosted-fields-example-rust"] diff --git a/rust-axum-js/src/main.rs b/rust-axum-js/src/main.rs index 8db4b7a..1547c67 100644 --- a/rust-axum-js/src/main.rs +++ b/rust-axum-js/src/main.rs @@ -313,11 +313,14 @@ async fn result(State(state): State, RawQuery(query): RawQuery) -> Res // stops a hand-edited URL: without it the page would happily poll somebody else's order. No // query at all is fine — the page then says there is nothing to show. let query = form_params(query.as_deref().unwrap_or_default()); - if let Some(order) = query.get("orderid").filter(|order| !order.is_empty()) - && !control::valid_callback(&query, &state.config.merchant_control) - { - eprintln!("[error] result signature mismatch for order {order:?}"); - return (StatusCode::FORBIDDEN, "invalid result signature").into_response(); + // Nested rather than a let chain: `if let … && …` is unstable before Rust 1.88, and this + // crate builds on the 1.85 its Cargo.toml declares. clippy will not ask to collapse these + // two, because it reads that same rust-version. + if let Some(order) = query.get("orderid").filter(|order| !order.is_empty()) { + if !control::valid_callback(&query, &state.config.merchant_control) { + eprintln!("[error] result signature mismatch for order {order:?}"); + return (StatusCode::FORBIDDEN, "invalid result signature").into_response(); + } } view(&state.config, "result.html")