Spaces:
Running
Running
File size: 10,800 Bytes
610aab7 58addbe 610aab7 b4f3a29 610aab7 58addbe 610aab7 ba90a93 58addbe 1511f7f 54de716 b4f3a29 58addbe b4f3a29 54de716 b4f3a29 54de716 58addbe b4f3a29 58addbe 610aab7 58addbe b4f3a29 58addbe 610aab7 2f9dfdb 610aab7 c04baab 610aab7 c04baab 610aab7 2f9dfdb 610aab7 58addbe 610aab7 b442b31 610aab7 1511f7f 58addbe 35516cf 58addbe | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 | # 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"] |