# 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://-.hf.space # Users' handles will be .. ENV PDS_HOSTNAME=cloudunity-gemma-api1.hf.space EXPOSE 7860 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]