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"]