Spaces:
Running
Running
| # syntax=docker/dockerfile:1 | |
| # ============================================================================= | |
| # Rochester-restricted Bluesky PDS + full Bluesky web app - single Dockerfile. | |
| # | |
| # The web client (bsky.app React Native Web SPA) is prebuilt by GitHub Actions | |
| # (workflow: .github/workflows/prebuild-web.yml) and force-pushed to the | |
| # `prebuilt-web` branch. This image simply copies that static bundle into the | |
| # official PDS image and patches it at boot to (a) restrict registration to | |
| # @rochesterschools.org and (b) serve the web app at / with an SPA fallback. | |
| # | |
| # No Node build happens here - so this builds fast and fits in low-memory | |
| # builders like HF Spaces free tier. | |
| # | |
| # Listens on port 7860 (HF forwards the public Space URL to it automatically). | |
| # ============================================================================= | |
| FROM node:24-bookworm-slim AS webapp | |
| RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* | |
| WORKDIR /bundle | |
| # The prebuilt bundle lives on the `prebuilt-web` branch (pushed by | |
| # prebuild-web.yml). Update SOURCE_REF to the commit of that branch whenever | |
| # the web app changes and the workflow has run. | |
| ARG SOURCE_REPO=https://github.com/CloudCompile/social-app.git | |
| ARG SOURCE_REF=prebuilt-web | |
| RUN git init . \ | |
| && git remote add origin ${SOURCE_REPO} \ | |
| && git fetch --depth 1 origin ${SOURCE_REF} \ | |
| && git checkout FETCH_HEAD | |
| RUN test -f index.html || { echo "prebuilt-web branch has no index.html - run the prebuild-web workflow first"; exit 1; } | |
| # --------------------------------------------------------------- server stage | |
| FROM ghcr.io/bluesky-social/pds:0.4 | |
| USER root | |
| # Web app bundle | |
| COPY --from=webapp /bundle /app/web | |
| # Write the email-allowlist patcher into the image. | |
| RUN <<'PATCH' | |
| cat > /app/allowlist-patch.cjs <<'EOF' | |
| /* | |
| * Patches the bundled @atproto/pds dist to enforce an email-domain allowlist | |
| * on com.atproto.server.createAccount. Idempotent; fails fast if upstream | |
| * code layout changes so the container never runs with open registration. | |
| */ | |
| const fs = require('node:fs') | |
| const path = require('node:path') | |
| const pdsDist = path.join(__dirname, 'node_modules', '@atproto', 'pds', 'dist') | |
| const domains = (process.env.PDS_EMAIL_ALLOWED_DOMAINS || '') | |
| .split(',') | |
| .map((d) => d.trim().toLowerCase().replace(/^\./, '')) | |
| .filter(Boolean) | |
| if (domains.length === 0) { | |
| console.error('[allowlist] PDS_EMAIL_ALLOWED_DOMAINS is not set; refusing to patch (open registration is not allowed)') | |
| process.exit(1) | |
| } | |
| function findTarget(dir) { | |
| for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { | |
| const full = path.join(dir, entry.name) | |
| if (entry.isDirectory()) { | |
| const found = findTarget(full) | |
| if (found) return found | |
| } else if ( | |
| entry.name === 'createAccount.js' && | |
| fs.readFileSync(full, 'utf8').includes('validateInputsForLocalPds') && | |
| fs.readFileSync(full, 'utf8').includes('isDisposableEmail') | |
| ) { | |
| return full | |
| } | |
| } | |
| return null | |
| } | |
| const target = findTarget(pdsDist) | |
| if (!target) { | |
| console.error('[allowlist] could not locate createAccount.js under ' + pdsDist) | |
| process.exit(1) | |
| } | |
| let source = fs.readFileSync(target, 'utf8') | |
| if (source.includes('__ROCHESTER_EMAIL_ALLOWLIST__')) { | |
| console.log('[allowlist] already patched, skipping') | |
| process.exit(0) | |
| } | |
| const conditionOld = '!isEmailValid(email) || isDisposableEmail(email)' | |
| const conditionNew = conditionOld + ' || !__rochesterEmailAllowed(email)' | |
| if (!source.includes(conditionOld)) { | |
| console.error('[allowlist] email validity condition not found; upstream layout changed') | |
| process.exit(1) | |
| } | |
| source = source.replace(conditionOld, conditionNew) | |
| const messageOld = "'This email address is not supported, please use a different email.'" | |
| const messageNew = "'Registration is restricted to " + domains.join(', ') + " email addresses.'" | |
| if (!source.includes(messageOld)) { | |
| console.error('[allowlist] error message anchor not found; upstream layout changed') | |
| process.exit(1) | |
| } | |
| source = source.replace(messageOld, messageNew) | |
| const helper = [ | |
| '', | |
| '// __ROCHESTER_EMAIL_ALLOWLIST__ start', | |
| 'function __rochesterEmailAllowed(email) {', | |
| " const domain = String(email || '').split('@')[1]?.toLowerCase()", | |
| ' return ' + JSON.stringify(domains) + '.includes(domain)', | |
| '}', | |
| '// __ROCHESTER_EMAIL_ALLOWLIST__ end', | |
| '', | |
| ].join('\n') | |
| const helperAnchor = 'export default function (server, ctx)' | |
| if (!source.includes(helperAnchor)) { | |
| console.error('[allowlist] helper injection point not found; upstream layout changed') | |
| process.exit(1) | |
| } | |
| source = source.replace(helperAnchor, helper + '\n' + helperAnchor) | |
| fs.writeFileSync(target, source) | |
| console.log('[allowlist] patched ' + target + ' (domains: ' + domains.join(', ') + ')') | |
| EOF | |
| PATCH | |
| # Write the entrypoint: generate secrets on first boot, patch, start. | |
| RUN <<'ENTRY' | |
| cat > /usr/local/bin/entrypoint.sh <<'EOF' | |
| #!/bin/sh | |
| set -e | |
| cd /app | |
| SECRETS_FILE=/app/data/generated-secrets.env | |
| mkdir -p /app/data | |
| # Generate persistent secrets on first boot so restarts keep sessions valid. | |
| # NOTE: a secp256k1 (K256) private key is just 32 random bytes, so no | |
| # @atproto/crypto import is needed (it is not resolvable under pnpm layout). | |
| if [ ! -f "$SECRETS_FILE" ]; then | |
| echo "[entrypoint] first boot - generating secrets" | |
| : > "$SECRETS_FILE" | |
| fi | |
| # Backfill any missing secret individually (covers files created by older | |
| # image versions that crashed mid-generation). | |
| . "$SECRETS_FILE" || true | |
| if [ -z "${PDS_JWT_SECRET:-}" ]; then | |
| PDS_JWT_SECRET=$(node -e 'console.log(require("crypto").randomBytes(16).toString("hex"))') | |
| echo "PDS_JWT_SECRET=$PDS_JWT_SECRET" >> "$SECRETS_FILE" | |
| fi | |
| if [ -z "${PDS_ADMIN_PASSWORD:-}" ]; then | |
| PDS_ADMIN_PASSWORD=$(node -e 'console.log(require("crypto").randomBytes(16).toString("hex"))') | |
| echo "PDS_ADMIN_PASSWORD=$PDS_ADMIN_PASSWORD" >> "$SECRETS_FILE" | |
| fi | |
| if [ -z "${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX:-}" ]; then | |
| PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))') | |
| echo "PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=$PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX" >> "$SECRETS_FILE" | |
| fi | |
| chmod 600 "$SECRETS_FILE" || true | |
| export PDS_JWT_SECRET PDS_ADMIN_PASSWORD PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX | |
| # PDS_HOSTNAME must be a bare hostname (no scheme, no trailing slash). | |
| # did:web is built from it; normalize common full-URL mistakes. | |
| PDS_HOSTNAME=$(node -e ' | |
| let h = process.env.PDS_HOSTNAME || "" | |
| h = h.trim().replace(/^https?:\/\//, "").replace(/\/+$/, "").replace(/:.*$/, "") | |
| console.log(h) | |
| ') | |
| export PDS_HOSTNAME | |
| echo "================================================================" | |
| echo " Generated secrets (saved in $SECRETS_FILE):" | |
| echo " PDS_ADMIN_PASSWORD: $PDS_ADMIN_PASSWORD" | |
| echo "================================================================" | |
| node allowlist-patch.cjs | |
| # Serve the bundled Bluesky web app at / with an SPA fallback. Patched into | |
| # basic-routes.js so the middleware is registered before the '/' banner route; | |
| # the fallback skips API paths (/xrpc, /.well-known, /tls-check, files with | |
| # extensions) so the PDS API keeps working. Idempotent. | |
| node - <<'WEBAPP' | |
| const fs = require('node:fs') | |
| const path = require('node:path') | |
| const MARKER = '__ROCHESTER_WEB_APP__' | |
| function findBasicRoutes(dir) { | |
| try { | |
| for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { | |
| const full = path.join(dir, entry.name) | |
| if (entry.isDirectory()) { | |
| const found = findBasicRoutes(full) | |
| if (found) return found | |
| } else if (entry.name === 'basic-routes.js') { | |
| return full | |
| } | |
| } | |
| } catch {} | |
| return null | |
| } | |
| const target = findBasicRoutes('/app/node_modules') || findBasicRoutes('/app/node_modules/.pnpm') | |
| if (!target) { | |
| console.error('[webapp] could not locate basic-routes.js') | |
| process.exit(1) | |
| } | |
| let src = fs.readFileSync(target, 'utf8') | |
| if (src.includes(MARKER)) { | |
| console.log('[webapp] already patched, skipping') | |
| process.exit(0) | |
| } | |
| const anchor = 'const router = Router()' | |
| if (!src.includes(anchor)) { | |
| console.error('[webapp] injection point not found in basic-routes.js') | |
| process.exit(1) | |
| } | |
| if (!src.includes("from 'express'") || !src.includes("import { Router }")) { | |
| console.error('[webapp] unexpected imports in basic-routes.js') | |
| process.exit(1) | |
| } | |
| const injection = [ | |
| anchor, | |
| ' // ' + MARKER + ' start', | |
| " const webDir = '/app/web'", | |
| ' router.use(express.static(webDir, {index: \'index.html\', maxAge: \'1h\'}))', | |
| ' router.get(\'*\', function (req, res, next) {', | |
| " if (req.path.startsWith('/xrpc/') || req.path.startsWith('/.') || req.path.startsWith('/tls-check')) return next()", | |
| ' if (path.extname(req.path)) return next()', | |
| " res.sendFile(path.join(webDir, 'index.html'))", | |
| ' })', | |
| ' // ' + MARKER + ' end', | |
| ].join('\n') | |
| src = src.replace(anchor, injection) | |
| if (!src.match(/^import path\b/m)) { | |
| src = "import path from 'node:path'\n" + src | |
| } | |
| // express.static needs the default export; basic-routes only imports {Router} | |
| if (!src.match(/^import express\b/m)) { | |
| src = "import express from 'express'\n" + src | |
| } | |
| fs.writeFileSync(target, src) | |
| console.log('[webapp] web app serving patched into ' + target) | |
| WEBAPP | |
| exec node --enable-source-maps index.ts | |
| EOF | |
| chmod +x /usr/local/bin/entrypoint.sh | |
| ENTRY | |
| # Data directory (SQLite + blobs). Enable persistent storage in the Space | |
| # settings to keep it across restarts. | |
| RUN mkdir -p /app/data \ | |
| && chown -R 1000:1000 /app \ | |
| && chown 1000:1000 /usr/local/bin/entrypoint.sh | |
| # HF Spaces runs containers as uid 1000; the runtime patch needs write access | |
| # to node_modules, hence the chown above. | |
| USER 1000 | |
| ENV PDS_DATA_DIRECTORY=/app/data | |
| # Disk blobstore - stores uploaded images/videos under /app/data (alongside | |
| # the SQLite db) so everything lives in the persistent volume. | |
| ENV PDS_BLOBSTORE_DISK_LOCATION=/app/data/blobs | |
| ENV PDS_BLOBSTORE_DISK_TMP_LOCATION=/app/data/blobs/tmp | |
| # HF Spaces route to port 7860 | |
| ENV PDS_PORT=7860 | |
| # Registration allowlist (comma-separated domains). The email domain check is | |
| # the only registration gate - no invite codes, no SMTP verification. | |
| ENV PDS_EMAIL_ALLOWED_DOMAINS=rochesterschools.org | |
| ENV PDS_INVITE_REQUIRED=false | |
| ENV PDS_EMAIL_SMTP_URL= | |
| ENV PDS_EMAIL_FROM_ADDRESS= | |
| # Skip email confirmation flow entirely (no SMTP on HF) | |
| ENV PDS_EMAIL_DISABLE_CONFIRMATION_LINK=true | |
| # Public hostname. Set this to your Space's public URL (as a Space secret or | |
| # variable named PDS_HOSTNAME) so the PDS advertises the right endpoint: | |
| # https://<owner>-<space>.hf.space | |
| # Users' handles will be <name>.<that hostname>. | |
| ENV PDS_HOSTNAME=cloudunity-gemma-api1.hf.space | |
| EXPOSE 7860 | |
| ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] |