gemma-api1 / Dockerfile
cloudunity's picture
Update Dockerfile
b4f3a29 verified
Raw
History Blame Contribute Delete
10.8 kB
# 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"]