Add SSH-provisioned TOTP login with JWT sessions
studio CI and deployment / verify-and-deploy (push) Successful in 1m14s
wiki CI and deployment / verify-and-deploy (push) Failing after 25m6s

This commit is contained in:
2026-10-27 00:12:12 +08:00
parent 71d1d28fd7
commit 0c1ed2d292
27 changed files with 627 additions and 19 deletions
+2
View File
@@ -9,6 +9,7 @@ on:
- 'compose.yml'
- 'scripts/deploy.sh'
- 'scripts/infra.sh'
- 'scripts/auth-admin.sh'
- 'scripts/integration.sh'
- 'pnpm-workspace.yaml'
pull_request:
@@ -19,6 +20,7 @@ on:
- 'compose.yml'
- 'scripts/deploy.sh'
- 'scripts/infra.sh'
- 'scripts/auth-admin.sh'
- 'scripts/integration.sh'
- 'pnpm-workspace.yaml'
workflow_dispatch:
+1 -1
View File
@@ -27,7 +27,7 @@ FROM base AS runtime
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
COPY --from=build --chown=node:node /app/.next/standalone/ ./
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs ./
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./
COPY --from=build --chown=node:node /app/db/ ./db/
COPY --chown=node:node apps/wiki/scripts/container-health.mjs ./
COPY --chown=node:node apps/wiki/scripts/import-character-packs.mjs ./scripts/import-character-packs.mjs
+1 -1
View File
@@ -27,7 +27,7 @@ FROM base AS runtime
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
COPY --from=build --chown=node:node /app/.next/standalone/ ./
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs ./
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./
COPY --from=build --chown=node:node /app/db/ ./db/
COPY --chown=node:node apps/wiki/scripts/container-health.mjs ./
COPY --chown=node:node apps/wiki/scripts/import-character-packs.mjs ./scripts/import-character-packs.mjs
+9
View File
@@ -0,0 +1,9 @@
BEGIN;
CREATE TABLE IF NOT EXISTS auth_login_state (
id text PRIMARY KEY,
attempts integer NOT NULL DEFAULT 0,
window_start timestamptz NOT NULL DEFAULT clock_timestamp(),
last_step bigint NOT NULL DEFAULT -1
);
INSERT INTO schema_migrations(version) VALUES ('007_dynamic_code_auth') ON CONFLICT DO NOTHING;
COMMIT;
+6 -4
View File
@@ -7,10 +7,10 @@
},
"scripts": {
"dev": "next dev",
"build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && node scripts/prepare-standalone.mjs",
"build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs",
"start": "next start",
"typecheck": "next typegen && tsc --noEmit",
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts",
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts",
"test:smoke": "node scripts/smoke.mjs",
"storage:prepare": "tsx scripts/bootstrap.ts",
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
@@ -21,22 +21,24 @@
"@aws-sdk/client-s3": "3.1155.0",
"formidable": "3.5.4",
"lucide-react": "1.61.0",
"mdast-util-from-markdown": "2.1.0",
"next": "16.4.2",
"pg": "8.23.1",
"playwright-core": "1.56.1",
"qrcode": "1.5.4",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"sharp": "0.35.5",
"zod": "4.6.7",
"mdast-util-from-markdown": "2.1.0"
"zod": "4.6.7"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/formidable": "3.5.1",
"@types/node": "24.19.2",
"@types/pg": "8.23.1",
"@types/qrcode": "1.5.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"esbuild": "0.28.2",
+226
View File
@@ -29,6 +29,9 @@ importers:
playwright-core:
specifier: 1.56.1
version: 1.56.1
qrcode:
specifier: 1.5.4
version: 1.5.4
react:
specifier: 19.3.1
version: 19.3.1
@@ -60,6 +63,9 @@ importers:
'@types/pg':
specifier: 8.23.1
version: 8.23.1
'@types/qrcode':
specifier: 1.5.6
version: 1.5.6
'@types/react':
specifier: 19.3.0
version: 19.3.0
@@ -702,6 +708,9 @@ packages:
'@types/pg@8.23.1':
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react-dom@19.3.0':
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
peerDependencies:
@@ -719,6 +728,14 @@ packages:
'@ungap/structured-clone@1.4.0':
resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
@@ -733,6 +750,10 @@ packages:
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001819:
resolution: {integrity: sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==}
@@ -754,6 +775,16 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -769,6 +800,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
@@ -786,6 +821,12 @@ packages:
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
enhanced-resolve@5.26.0:
resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==}
engines: {node: '>=10.13.0'}
@@ -805,6 +846,10 @@ packages:
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
formidable@3.5.4:
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
engines: {node: '>=14.0.0'}
@@ -814,6 +859,10 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -838,6 +887,10 @@ packages:
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
@@ -923,6 +976,10 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -1105,9 +1162,25 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
pg-cloudflare@1.4.1:
resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==}
@@ -1150,6 +1223,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss@8.5.23:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
@@ -1177,6 +1254,11 @@ packages:
property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
react-dom@19.3.1:
resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==}
peerDependencies:
@@ -1204,6 +1286,13 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
scheduler@0.28.0:
resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
@@ -1212,6 +1301,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
sharp@0.35.5:
resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
engines: {node: '>=20.9.0'}
@@ -1232,9 +1324,17 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
@@ -1307,6 +1407,13 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -1314,6 +1421,17 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
zod@4.6.7:
resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==}
@@ -1869,6 +1987,10 @@ snapshots:
pg-protocol: 1.16.1
pg-types: 2.2.0
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 24.19.2
'@types/react-dom@19.3.0(@types/react@19.3.0)':
dependencies:
'@types/react': 19.3.0
@@ -1883,6 +2005,12 @@ snapshots:
'@ungap/structured-clone@1.4.0': {}
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
asap@2.0.6: {}
bail@2.0.2: {}
@@ -1891,6 +2019,8 @@ snapshots:
bowser@2.14.1: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001819: {}
ccount@2.0.1: {}
@@ -1905,6 +2035,18 @@ snapshots:
client-only@0.0.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
comma-separated-tokens@2.0.3: {}
csstype@3.2.3: {}
@@ -1913,6 +2055,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decode-named-character-reference@1.3.0:
dependencies:
character-entities: 2.0.2
@@ -1930,6 +2074,10 @@ snapshots:
asap: 2.0.6
wrappy: 1.0.2
dijkstrajs@1.0.3: {}
emoji-regex@8.0.0: {}
enhanced-resolve@5.26.0:
dependencies:
graceful-fs: 4.2.11
@@ -1970,6 +2118,11 @@ snapshots:
extend@3.0.2: {}
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
formidable@3.5.4:
dependencies:
'@paralleldrive/cuid2': 2.3.1
@@ -1979,6 +2132,8 @@ snapshots:
fsevents@2.3.3:
optional: true
get-caller-file@2.0.5: {}
graceful-fs@4.2.11: {}
hast-util-to-jsx-runtime@2.3.6:
@@ -2018,6 +2173,8 @@ snapshots:
is-decimal@2.0.1: {}
is-fullwidth-code-point@3.0.0: {}
is-hexadecimal@2.0.1: {}
is-plain-obj@4.1.0: {}
@@ -2073,6 +2230,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
longest-streak@3.1.0: {}
lucide-react@1.61.0(@types/react@19.3.0)(react@19.3.1):
@@ -2472,6 +2633,16 @@ snapshots:
dependencies:
wrappy: 1.0.2
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-try@2.2.0: {}
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
@@ -2482,6 +2653,8 @@ snapshots:
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
path-exists@4.0.0: {}
pg-cloudflare@1.4.1:
optional: true
@@ -2521,6 +2694,8 @@ snapshots:
playwright-core@1.56.1: {}
pngjs@5.0.0: {}
postcss@8.5.23:
dependencies:
nanoid: 3.3.20
@@ -2545,6 +2720,12 @@ snapshots:
property-information@7.2.0: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
react-dom@19.3.1(react@19.3.1):
dependencies:
react: 19.3.1
@@ -2604,10 +2785,16 @@ snapshots:
mdast-util-to-markdown: 2.2.0
unified: 11.0.5
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
scheduler@0.28.0: {}
semver@7.8.5: {}
set-blocking@2.0.0: {}
sharp@0.35.5(@types/node@24.19.2):
dependencies:
'@img/colour': 1.1.0
@@ -2647,11 +2834,21 @@ snapshots:
split2@4.2.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
character-entities-legacy: 3.0.0
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
@@ -2728,10 +2925,39 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
which-module@2.0.1: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
xtend@4.0.2: {}
y18n@4.0.3: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
zod@4.6.7: {}
zwitch@2.0.4: {}
+81
View File
@@ -0,0 +1,81 @@
import { mkdirSync, writeFileSync, existsSync, renameSync, chmodSync, chownSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import assert from 'node:assert/strict';
import QRCode from 'qrcode';
import { authConfig, newAuthConfig, signSession, totp } from '../src/lib/auth';
import { db } from '../src/lib/db';
async function main() {
const command = process.argv[2];
const file = process.env.AUTH_CONFIG_FILE;
if (!file) throw Error('Set AUTH_CONFIG_FILE before running the server authentication tool');
if (command === 'init' || command === 'rotate') {
if (command === 'rotate' && !process.argv.includes('--confirm')) throw Error('Rotation invalidates all authenticators and sessions; pass --confirm to proceed');
if (command === 'init' && existsSync(file)) { authConfig(); console.log('Authentication configuration already exists; preserved'); return; }
mkdirSync(dirname(file),{recursive:true,mode:0o700});
const temporary = file + '.' + process.pid + '.tmp';
writeFileSync(temporary,JSON.stringify(newAuthConfig())+'\n',{mode:0o600,flag:'wx'});
if (process.getuid?.() === 0) { chownSync(dirname(file),1000,1000); chownSync(temporary,1000,1000); }
chmodSync(dirname(file),0o700); renameSync(temporary,file);
console.log('Authentication configuration saved privately. Use the qr command in an SSH terminal to bind.'); return;
}
const config = authConfig(); if (!config) throw Error('Authentication is not configured');
if (command === 'qr') {
if (!process.stdout.isTTY) throw Error('QR display requires an interactive SSH terminal (ssh -t, docker compose exec without -T). Never send QR output to CI logs.');
const uri = 'otpauth://totp/waterfall-wiki:shared?'+new URLSearchParams({secret:config.secret,issuer:'waterfall-wiki',algorithm:'SHA1',digits:'6',period:'30'});
console.log('Private binding QR — everyone scanning this uses the same authenticator key.');
console.log(await QRCode.toString(uri,{type:'terminal',small:true,errorCorrectionLevel:'M'}));
return;
}
if (command === 'token') {
if (!process.stdout.isTTY) throw Error('Tokens may only be displayed in an interactive SSH terminal');
console.log(signSession(config,undefined,3600)); return;
}
const base = process.env.AUTH_CHECK_BASE_URL || 'http://127.0.0.1:3000';
if (command === 'run') {
const script = process.argv[3]; if (!script) throw Error('Provide a server-side script path');
const original = globalThis.fetch; const token = signSession(config,undefined,600);
// Only attach the credential to the local application, never to remote fetches.
globalThis.fetch = (input,init) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.origin === new URL(base).origin) { const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined)); headers.set('Authorization','Bearer '+token); return original(input,{...init,headers}); }
return original(input,init);
};
await import(pathToFileURL(resolve(script)).href); return;
}
if (command === 'verify' || command === 'exercise') {
const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'});
for (const path of ['/','/library/import','/skills/shadow-puppet/egret-example.png','/studio','/_next/data/auth-test/index.json']) {
const response = await request(path); assert.equal(response.status,307,path); assert.match(response.headers.get('location') || '',/\/login\?/);
}
for (const path of ['/api/feed','/api/upload','/studio/api/export/test','/api/media/missing']) assert.equal((await request(path)).status,401,path);
assert.equal((await request('/api/posts',{method:'POST',body:'{}'})).status,401);
assert.equal((await request('/api/feed',{headers:{'x-middleware-subrequest':'proxy:proxy:proxy:proxy:proxy'}})).status,401);
const page = await request('/login'); assert.equal(page.status,200); const html = await page.text(); assert.ok(html.includes('输入动态码')); assert.ok(!html.includes(config.secret));
assert.equal((await request('/api/health')).status,200);
const headers = {Authorization:'Bearer '+signSession(config,undefined,600)};
assert.equal((await request('/api/feed',{headers})).status,200);
assert.equal((await request('/api/feed',{headers:{Authorization:headers.Authorization+'x'}})).status,401);
if (command === 'exercise') {
const origin = new URL(base).origin;
const login = (code:string,customOrigin=origin) => request('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json',Origin:customOrigin},body:JSON.stringify({code})});
assert.equal((await login('000000','https://untrusted.example')).status,403);
const code = totp(config.secret,Math.floor(Date.now()/30000));
const results = await Promise.all([login(code),login(code)]);
assert.deepEqual(results.map(r=>r.status).sort(),[200,401],'exactly one concurrent use succeeds');
const cookie = results.find(r=>r.status===200)!.headers.get('set-cookie')!;
assert.match(cookie,/HttpOnly/); assert.match(cookie,/SameSite=Lax/); assert.match(cookie,/Max-Age=604800/);
const session = cookie.split(';')[0];
assert.equal((await request('/api/feed',{headers:{Cookie:session}})).status,200);
assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:session,Origin:'https://untrusted.example'}})).status,403);
const logout = await request('/api/auth/logout',{method:'POST',headers:{Cookie:session,Origin:origin}}); assert.equal(logout.status,200); assert.match(logout.headers.get('set-cookie')!,/Max-Age=0/);
let status = 0; for (let i=0;i<21;i++) status=(await login('invalid')).status; assert.equal(status,429);
// Durable throttling remains even after this test process exits.
assert.equal((await login(code)).status,429);
}
console.log('Dynamic-code authentication '+command+' passed (no secrets displayed)'); return;
}
throw Error('Usage: auth-cli.cjs init | qr | rotate --confirm | token | run <script> | verify | exercise');
}
main().catch(error=>{console.error(error instanceof Error?error.message:'Authentication command failed');process.exitCode=1;}).finally(async()=>{if (process.env.DATABASE_URL) await db().end().catch(()=>{});});
+1 -1
View File
@@ -10,6 +10,6 @@ export function Shell({children}:{children:ReactNode}){
<aside className={`sidebar ${open?'is-open':''}`}><Link href="/" className="brand" aria-label="waterfall-wiki 首页" onClick={()=>setOpen(false)}><img className="brand-mark" src="/brand-mark.svg" width={40} height={40} alt=""/><strong>waterfall-wiki</strong></Link>
<nav>{groups.map((g,i)=><div className="nav-group" key={i}>{g.label&&<p>{g.label}</p>}{g.items.map(item=>{const active=item.href==='/'?router.asPath.split('?')[0]==='/':router.asPath.split('?')[0].startsWith(item.href);return <Link key={item.href} href={item.href} title={item.label} className={`nav-item ${active?'active':''}`} onClick={()=>setOpen(false)}><item.icon size={24} strokeWidth={active?2.2:1.8}/><span>{item.label}</span></Link>;})}</div>)}</nav>
</aside><main className="main-content"><div className="global-topbar"><form className="global-search" role="search" onSubmit={e=>{e.preventDefault();const path=router.asPath.split('?')[0];const destination=path==='/categories'?path:'/';void router.push({pathname:destination,query:{...(destination==='/categories'&&router.query.category?{category:router.query.category}:{}),...(query.trim()?{q:query.trim()}:{})}});}}><Search size={24}/><input aria-label="搜索笔记全文" placeholder="搜索知识、灵感和素材" value={query} onChange={e=>setQuery(e.target.value)}/>{query&&<button type="button" aria-label="清空搜索" onClick={()=>{setQuery('');void router.push({pathname:router.pathname==='/categories'?'/categories':'/',query:router.query.category?{category:router.query.category}:{}})}}><X size={20}/></button>}<button type="submit" className="search-submit">搜索</button></form></div>{children}</main>{open&&<button className="nav-scrim" aria-label="关闭导航" onClick={()=>setOpen(false)}/>}
<button className="auth-logout" onClick={async()=>{const response=await fetch('/api/auth/logout',{method:'POST'});if(response.ok)window.location.assign('/login');}}>退出登录</button></aside><main className="main-content"><div className="global-topbar"><form className="global-search" role="search" onSubmit={e=>{e.preventDefault();const path=router.asPath.split('?')[0];const destination=path==='/categories'?path:'/';void router.push({pathname:destination,query:{...(destination==='/categories'&&router.query.category?{category:router.query.category}:{}),...(query.trim()?{q:query.trim()}:{})}});}}><Search size={24}/><input aria-label="搜索笔记全文" placeholder="搜索知识、灵感和素材" value={query} onChange={e=>setQuery(e.target.value)}/>{query&&<button type="button" aria-label="清空搜索" onClick={()=>{setQuery('');void router.push({pathname:router.pathname==='/categories'?'/categories':'/',query:router.query.category?{category:router.query.category}:{}})}}><X size={20}/></button>}<button type="submit" className="search-submit">搜索</button></form></div>{children}</main>{open&&<button className="nav-scrim" aria-label="关闭导航" onClick={()=>setOpen(false)}/>}
</div>;
}
+19
View File
@@ -0,0 +1,19 @@
import { transaction } from './db';
import { authId, matchingStep, type AuthConfig } from './auth';
// A database row serializes all login attempts across workers and restarts.
export async function consumeCode(config: AuthConfig, code: string): Promise<'ok'|'invalid'|'limited'> {
return transaction(async client => {
const id = authId(config);
await client.query('INSERT INTO auth_login_state(id) VALUES($1) ON CONFLICT DO NOTHING',[id]);
const {rows:[state]} = await client.query('SELECT *, extract(epoch FROM clock_timestamp()) * 1000 AS now_ms FROM auth_login_state WHERE id=$1 FOR UPDATE',[id]);
const now = Number(state.now_ms);
const expired = now - new Date(state.window_start).getTime() >= 300000;
const attempts = expired ? 0 : state.attempts;
if (attempts >= 20) return 'limited';
await client.query('UPDATE auth_login_state SET attempts=$2, window_start=CASE WHEN $3 THEN clock_timestamp() ELSE window_start END WHERE id=$1',[id,attempts+1,expired]);
const step = matchingStep(config.secret,code,now);
if (step === null || step <= Number(state.last_step)) return 'invalid';
await client.query('UPDATE auth_login_state SET last_step=$2 WHERE id=$1',[id,step]);
return 'ok';
});
}
+58
View File
@@ -0,0 +1,58 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFileSync } from 'node:fs';
export const SESSION_COOKIE = 'prompt_session';
export const SESSION_SECONDS = 7 * 24 * 60 * 60;
export type AuthConfig = { secret: string; jwtKey: string };
export function authConfig(): AuthConfig | null {
const path = process.env.AUTH_CONFIG_FILE;
if (!path) return null;
const value = JSON.parse(readFileSync(path, 'utf8')) as AuthConfig;
if (!/^[A-Z2-7]{32}$/.test(value.secret) || !/^[a-f0-9]{64}$/.test(value.jwtKey)) throw Error('Invalid authentication configuration');
return value;
}
export function base32(bytes: Buffer) {
let bits = 0, value = 0, output = '';
for (const byte of bytes) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { bits -= 5; output += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'[(value >>> bits) & 31]; } }
if (bits) output += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'[(value << (5 - bits)) & 31];
return output;
}
export function newAuthConfig(): AuthConfig { return {secret: base32(randomBytes(20)), jwtKey: randomBytes(32).toString('hex')}; }
export function authId(config: AuthConfig) { return createHash('sha256').update(config.jwtKey).digest('hex'); }
export function totp(secret: string, step: number, digits = 6) {
let bits = 0, value = 0; const bytes: number[] = [];
for (const char of secret) { const index = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'.indexOf(char); if (index < 0) throw Error('Invalid TOTP key'); value = (value << 5) | index; bits += 5; if (bits >= 8) { bits -= 8; bytes.push((value >>> bits) & 255); } }
const counter = Buffer.alloc(8); counter.writeBigUInt64BE(BigInt(step));
const hmac = createHmac('sha1', Buffer.from(bytes)).update(counter).digest();
return String((hmac.readUInt32BE(hmac[19] & 15) & 0x7fffffff) % 10 ** digits).padStart(digits, '0');
}
export function matchingStep(secret: string, code: string, now = Date.now()) {
if (!/^\d{6}$/.test(code)) return null;
const current = Math.floor(now / 30000);
for (const step of [current, current - 1, current + 1]) if (timingSafeEqual(Buffer.from(totp(secret, step)), Buffer.from(code))) return step;
return null;
}
export function signSession(config: AuthConfig, now = Math.floor(Date.now() / 1000), lifetime = SESSION_SECONDS) {
const header = Buffer.from(JSON.stringify({alg:'HS256',typ:'JWT'})).toString('base64url');
const payload = Buffer.from(JSON.stringify({iss:'prompt',aud:'prompt-web',sub:'shared',iat:now,exp:now+lifetime,jti:randomBytes(16).toString('hex')})).toString('base64url');
const content = header + '.' + payload;
return content + '.' + createHmac('sha256', Buffer.from(config.jwtKey,'hex')).update(content).digest('base64url');
}
export function validSession(token: string | undefined, config: AuthConfig, now = Math.floor(Date.now() / 1000)) {
try {
if (!token || token.length > 2048) return false;
const parts = token.split('.'); if (parts.length !== 3) return false;
const [header,payload,signature] = parts;
const h = JSON.parse(Buffer.from(header,'base64url').toString());
if (h.alg !== 'HS256' || h.typ !== 'JWT') return false;
const expected = createHmac('sha256',Buffer.from(config.jwtKey,'hex')).update(header+'.'+payload).digest();
const actual = Buffer.from(signature,'base64url');
if (actual.length !== expected.length || !timingSafeEqual(actual,expected)) return false;
const p = JSON.parse(Buffer.from(payload,'base64url').toString());
return p.iss === 'prompt' && p.aud === 'prompt-web' && p.sub === 'shared' && Number.isInteger(p.exp) && Number.isInteger(p.iat) && p.iat <= now + 30 && p.exp > now && p.exp > p.iat && p.exp - p.iat <= SESSION_SECONDS;
} catch { return false; }
}
export function sessionCookie(token: string, clear = false) {
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${clear ? 0 : SESSION_SECONDS}${process.env.AUTH_COOKIE_SECURE === 'false' ? '' : '; Secure'}`;
}
export function safeReturn(value: unknown) { return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//') && !/[\\\r\n]/.test(value) && !value.startsWith('/login') ? value : '/'; }
+20
View File
@@ -0,0 +1,20 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { authConfig, sessionCookie, signSession } from '@/lib/auth';
import { consumeCode } from '@/lib/auth-store';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
res.setHeader('Cache-Control','no-store');
if (req.method !== 'POST') { res.setHeader('Allow','POST'); return res.status(405).end(); }
try {
const origin = req.headers.origin;
if (!origin || new URL(origin).host !== req.headers.host || req.headers['sec-fetch-site'] === 'cross-site') return res.status(403).json({error:'请求来源不受信任'});
const config = authConfig();
if (!config) return res.status(503).json({error:'尚未配置动态码登录,请联系管理员'});
const code = typeof req.body?.code === 'string' ? req.body.code.trim() : '';
const result = await consumeCode(config,code);
if (result === 'limited') { res.setHeader('Retry-After','300'); return res.status(429).json({error:'尝试次数过多,请五分钟后再试'}); }
if (result !== 'ok') return res.status(401).json({error:'动态码不正确或已使用,请等待下一组动态码'});
res.setHeader('Set-Cookie',sessionCookie(signSession(config)));
return res.status(200).json({ok:true});
} catch { return res.status(503).json({error:'登录服务暂时不可用,请稍后重试'}); }
}
export const config = {api:{bodyParser:{sizeLimit:'1kb'}}};
+8
View File
@@ -0,0 +1,8 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { sessionCookie } from '@/lib/auth';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
res.setHeader('Cache-Control','no-store');
if (req.method !== 'POST') { res.setHeader('Allow','POST'); return res.status(405).end(); }
try { if (!req.headers.origin || new URL(req.headers.origin).host !== req.headers.host || req.headers['sec-fetch-site'] === 'cross-site') return res.status(403).end(); } catch { return res.status(403).end(); }
res.setHeader('Set-Cookie',sessionCookie('',true)); return res.status(200).json({ok:true});
}
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){
res.setHeader('Content-Type',object.ContentType||'application/octet-stream');res.setHeader('X-Content-Type-Options','nosniff');res.setHeader('Accept-Ranges','bytes');
if(object.ContentLength!==undefined)res.setHeader('Content-Length',object.ContentLength);
if(object.ContentRange)res.setHeader('Content-Range',object.ContentRange);
res.setHeader('Cache-Control','private, max-age=3600');
res.setHeader('Cache-Control','private, no-store');
const stream=object.Body as Readable;
if(req.method==='HEAD'){stream.destroy();return res.end();}
res.on('close',()=>stream.destroy());stream.on('error',()=>res.destroy());return stream.pipe(res);
+1 -1
View File
@@ -4,5 +4,5 @@ import {db} from '@/lib/db';
import {getObject} from '@/lib/storage';
import {previewWidth,cachedPreview,loadStaticPreview} from '@/lib/preview';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/api/preview'))return;if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const width=previewWidth(req.query.width);if(!width||typeof req.query.source!=='string')return res.status(400).end();try{const source=req.query.source;let input:{identity:string;load:()=>Promise<Buffer>};if(source.startsWith('/api/media/')){const match=source.match(/^\/api\/media\/([0-9a-f-]{36})(?:\?variant=(poster))?$/);if(!match)return res.status(400).end();const result=match[2]?await db().query('SELECT storage_key,mime_type FROM assets WHERE source_asset_id=$1 AND derivation=$2',[match[1],match[2]]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[match[1]]);const row=result.rows[0];if(!row?.storage_key||!row.mime_type?.startsWith('image/'))return res.status(404).end();input={identity:row.storage_key,load:async()=>{const object=await getObject(row.storage_key);if((object.ContentLength||0)>64*1024*1024)throw Error('Image too large');const chunks:Buffer[]=[];let length=0;for await(const chunk of object.Body as AsyncIterable<Uint8Array>){length+=chunk.length;if(length>64*1024*1024)throw Error('Image too large');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}};}else input=await loadStaticPreview(source);const {bytes,etag}=await cachedPreview(input.identity,width,input.load);res.setHeader('ETag',etag);res.setHeader('Cache-Control','public, max-age=3600');res.setHeader('Content-Type','image/webp');res.setHeader('X-Content-Type-Options','nosniff');if(req.headers['if-none-match']===etag)return res.status(304).end();res.setHeader('Content-Length',bytes.length);return req.method==='HEAD'?res.end():res.send(bytes);}catch(e){const message=(e as Error).message;if(message.includes('Invalid source'))return res.status(400).end();if((e as NodeJS.ErrnoException).code==='ENOENT')return res.status(404).end();res.setHeader('Retry-After','5');return res.status(503).end();}}
if(serveApiDocumentation(req,res,'/api/preview'))return;if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const width=previewWidth(req.query.width);if(!width||typeof req.query.source!=='string')return res.status(400).end();try{const source=req.query.source;let input:{identity:string;load:()=>Promise<Buffer>};if(source.startsWith('/api/media/')){const match=source.match(/^\/api\/media\/([0-9a-f-]{36})(?:\?variant=(poster))?$/);if(!match)return res.status(400).end();const result=match[2]?await db().query('SELECT storage_key,mime_type FROM assets WHERE source_asset_id=$1 AND derivation=$2',[match[1],match[2]]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[match[1]]);const row=result.rows[0];if(!row?.storage_key||!row.mime_type?.startsWith('image/'))return res.status(404).end();input={identity:row.storage_key,load:async()=>{const object=await getObject(row.storage_key);if((object.ContentLength||0)>64*1024*1024)throw Error('Image too large');const chunks:Buffer[]=[];let length=0;for await(const chunk of object.Body as AsyncIterable<Uint8Array>){length+=chunk.length;if(length>64*1024*1024)throw Error('Image too large');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}};}else input=await loadStaticPreview(source);const {bytes,etag}=await cachedPreview(input.identity,width,input.load);res.setHeader('ETag',etag);res.setHeader('Cache-Control','private, no-store');res.setHeader('Content-Type','image/webp');res.setHeader('X-Content-Type-Options','nosniff');if(req.headers['if-none-match']===etag)return res.status(304).end();res.setHeader('Content-Length',bytes.length);return req.method==='HEAD'?res.end():res.send(bytes);}catch(e){const message=(e as Error).message;if(message.includes('Invalid source'))return res.status(400).end();if((e as NodeJS.ErrnoException).code==='ENOENT')return res.status(404).end();res.setHeader('Retry-After','5');return res.status(503).end();}}
export const config={api:{responseLimit:false}};
@@ -9,7 +9,7 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/api/skills/street-people'))return;
res.setHeader("Allow","GET, POST");
if(req.method==="GET"){
res.setHeader("Cache-Control","public, max-age=300");
res.setHeader("Cache-Control","private, no-store");
if(req.query.format==="md")return res.setHeader("Content-Type","text/markdown; charset=utf-8").status(200).send(guidanceMarkdown("street-people"));
return res.status(200).json(streetPeople);
}
+17
View File
@@ -0,0 +1,17 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useState, type FormEvent } from 'react';
// Keep this page independent of server-only authentication modules.
export default function Login() {
const router = useRouter(); const [code,setCode] = useState(''); const [error,setError] = useState(''); const [busy,setBusy] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault(); if (busy) return; setBusy(true); setError('');
try {
const response = await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({code})});
const data = await response.json(); if (!response.ok) { setError(data.error || '登录没有完成'); setCode(''); return; }
const next = router.query.next;
window.location.assign(typeof next === 'string' && next.startsWith('/') && !next.startsWith('//') && !/[\\\r\n]/.test(next) && !next.startsWith('/login') ? next : '/');
} catch { setError('暂时无法连接,请稍后重试'); } finally { setBusy(false); }
}
return <><Head><title>动态码登录 · waterfall-wiki</title><meta name="robots" content="noindex,nofollow"/></Head><main className="auth-page"><form className="auth-card" onSubmit={submit}><div className="auth-mark">W</div><p className="auth-brand">waterfall-wiki</p><h1>输入动态码</h1><p className="auth-description">打开验证器,输入当前的 6 位动态码。</p><label htmlFor="auth-code">动态码</label><input id="auth-code" autoFocus inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} required value={code} onChange={e=>setCode(e.target.value.replace(/\D/g,''))} placeholder="000000" aria-describedby="auth-error"/><div id="auth-error" role="alert" className="auth-error">{error}</div><button type="submit" disabled={busy || code.length !== 6}>{busy ? '正在验证…' : '进入知识库'}</button><p className="auth-footnote">尚未绑定验证器?请联系管理员。</p></form></main></>;
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse, type NextRequest } from 'next/server';
import { authConfig, SESSION_COOKIE, validSession } from './lib/auth';
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
const responseHeaders = {'Cache-Control':'private, no-store', 'Referrer-Policy':'same-origin'};
// Only login code assets and minimal health probes are public. Images, documents,
// rewrites and /_next/data are protected along with pages and API endpoints.
if (path === '/login' || path === '/api/auth/login' || path === '/api/auth/logout' || path === '/api/health' || path === '/api/ready' || path.startsWith('/_next/static/')) return NextResponse.next({headers:responseHeaders});
let config;
try { config = authConfig(); } catch { return NextResponse.json({error:'登录服务配置不可用'}, {status:503,headers:responseHeaders}); }
if (!config) return NextResponse.next();
const bearer = request.headers.get('authorization');
const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : request.cookies.get(SESSION_COOKIE)?.value;
if (validSession(token,config)) {
if (!['GET','HEAD','OPTIONS'].includes(request.method) && !bearer) {
const origin = request.headers.get('origin');
const host = request.headers.get('host');
try { if (!origin || new URL(origin).host !== host || request.headers.get('sec-fetch-site') === 'cross-site') return NextResponse.json({error:'请求来源不受信任'}, {status:403,headers:responseHeaders}); } catch { return NextResponse.json({error:'请求来源不受信任'}, {status:403,headers:responseHeaders}); }
}
return NextResponse.next({headers:responseHeaders});
}
if (path.startsWith('/api/') || path.startsWith('/studio/api/') || !['GET','HEAD'].includes(request.method)) return NextResponse.json({error:'请先使用动态码登录'}, {status:401,headers:responseHeaders});
const login = request.nextUrl.clone(); login.pathname = '/login'; login.search = ''; login.searchParams.set('next',path + request.nextUrl.search);
return NextResponse.redirect(login,{headers:responseHeaders});
}
+3
View File
@@ -45,3 +45,6 @@
.note-editor-actions{display:flex;justify-content:flex-end;gap:12px;position:sticky;bottom:-28px;padding:14px 0;background:#fff;border-top:1px solid #eee;margin-top:24px}
.note-editor .tag-input-row input{width:0}.note-editor .tag-input-selected button,.note-editor .tag-input-suggestions button{font:inherit;font-size:12px}
.brand strong{font-size:17px;letter-spacing:-.6px;overflow-wrap:anywhere}.global-search .search-submit{font-size:13px;white-space:nowrap;padding:7px 12px;border-radius:18px;background:#fff;color:#444}.backlink-count{display:inline-flex;align-items:center;gap:5px;color:#737b79;font-size:12px;margin:0 2px 10px}.backlink-count:hover{color:#315775;text-decoration:underline}.search-results-heading{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;margin:0 0 24px;font-size:15px}.search-results-heading span{color:#888;font-size:12px}.discovery-card .search-excerpt{font-size:12px;line-height:1.7;color:#6a746c;border-left:2px solid #ccd5ce;padding-left:9px;margin:0 2px 12px;overflow-wrap:anywhere}.note-backlinks{border-top:1px solid #eee;margin-top:26px;padding-top:18px}.note-backlinks h2{font-size:15px;font-weight:600}.note-backlinks ul{list-style:none;padding:0;display:grid;gap:12px}.note-backlinks a{font-size:13px;line-height:1.7}.note-backlinks p{font-size:13px;color:#888}
/* Dynamic-code sign in */
.auth-page{min-height:100dvh;display:grid;place-items:center;padding:24px;background:#f6f7f9;color:#20242c}.auth-card{width:100%;max-width:400px;padding:40px 32px;background:#fff;border:1px solid #e5e7eb;border-radius:24px;box-shadow:0 12px 48px #18223808}.auth-mark{width:44px;height:44px;border-radius:14px;background:#20242c;color:white;display:grid;place-items:center;font-weight:800;font-size:22px}.auth-brand{font-size:13px;color:#737986;margin:16px 0 28px}.auth-card h1{font-size:28px;margin:0 0 12px;letter-spacing:-1px}.auth-description,.auth-footnote{font-size:14px;line-height:1.7;color:#737986}.auth-card label{display:block;font-size:13px;margin:28px 0 10px}.auth-card input{box-sizing:border-box;width:100%;min-width:0;padding:14px;border:1px solid #d8dce3;border-radius:12px;font-size:30px;letter-spacing:.3em;text-align:center;color:#20242c;background:#fff}.auth-card input:focus{outline:2px solid #6276e5;outline-offset:3px}.auth-card>button{width:100%;padding:14px;border:0;border-radius:12px;background:#20242c;color:white;font-size:15px;font-weight:600;cursor:pointer}.auth-card>button:disabled{opacity:.45;cursor:default}.auth-error{font-size:13px;color:#b52c2c;min-height:44px;padding-top:10px;line-height:1.6}.auth-footnote{text-align:center;font-size:12px;margin-top:24px}.auth-logout{margin:24px 16px;padding:10px 14px;border:1px solid #e5e7eb;border-radius:10px;background:white;color:#737986;cursor:pointer}
+46
View File
@@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {base32,totp,matchingStep,newAuthConfig,signSession,validSession,sessionCookie,safeReturn,authConfig} from '../src/lib/auth';
import {proxy} from '../src/proxy';
import {NextRequest} from 'next/server';
import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
test('TOTP matches RFC 6238 SHA-1 test vectors',()=>{
const secret=base32(Buffer.from('12345678901234567890'));
for(const [time,expected] of [[59,'94287082'],[1111111109,'07081804'],[1111111111,'14050471'],[1234567890,'89005924'],[2000000000,'69279037'],[20000000000,'65353130']] as const) assert.equal(totp(secret,Math.floor(time/30),8),expected);
assert.equal(matchingStep(secret,totp(secret,100),3000000),100);
assert.equal(matchingStep(secret,totp(secret,99),3000000),99);
assert.equal(matchingStep(secret,totp(secret,98),3000000),null);
assert.equal(matchingStep(secret,'123',3000000),null);
});
test('JWT rejects tampering, expiry, wrong keys and malformed values',()=>{
const config=newAuthConfig(),token=signSession(config,1000);
assert.equal(validSession(token,config,1000),true);
assert.equal(validSession(token,config,1000+604800),false);
assert.equal(validSession(token,config,900),false);
assert.equal(validSession(token,newAuthConfig(),1000),false);
assert.equal(validSession(token+'x',config,1000),false);
assert.equal(validSession('eyJhbGciOiJub25lIn0.e30.',config,1000),false);
assert.equal(validSession(undefined,config),false);
});
test('cookies are HTTP-only and secure by default; redirects stay local',()=>{
assert.match(sessionCookie('test'),/HttpOnly; SameSite=Lax; Max-Age=604800; Secure$/);
assert.match(sessionCookie('',true),/Max-Age=0/);
for(const path of ['https://evil.test','//evil.test','/\\evil.test','/login?next=/','/\nLocation: bad']) assert.equal(safeReturn(path),'/');
assert.equal(safeReturn('/explore/123?q=test'),'/explore/123?q=test');
});
test('proxy protects APIs, data routes, static content and Studio; fails closed',()=>{
const temp=mkdtempSync(join(tmpdir(),'prompt-auth-')); const old=process.env.AUTH_CONFIG_FILE;
try{
const config=newAuthConfig(); process.env.AUTH_CONFIG_FILE=join(temp,'config.json'); writeFileSync(process.env.AUTH_CONFIG_FILE,JSON.stringify(config));
for(const path of ['/','/library/import','/skills/example.png','/_next/data/build/index.json','/studio']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,307,path);
for(const path of ['/api/upload','/studio/api/export','/api/feed']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,401,path);
for(const path of ['/login','/_next/static/chunk.js','/api/health','/api/ready']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,200,path);
const headers={cookie:'prompt_session='+signSession(config),host:'wiki.test'};
assert.equal(proxy(new NextRequest('http://wiki.test/api/feed',{headers})).status,200);
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{...headers,origin:'http://evil.test'}})).status,403);
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{...headers,origin:'http://wiki.test'}})).status,200);
writeFileSync(process.env.AUTH_CONFIG_FILE,'{}'); assert.throws(()=>authConfig()); assert.equal(proxy(new NextRequest('http://wiki.test/')).status,503);
}finally{if(old===undefined)delete process.env.AUTH_CONFIG_FILE;else process.env.AUTH_CONFIG_FILE=old;rmSync(temp,{recursive:true,force:true});}
});
+15
View File
@@ -11,12 +11,16 @@ x-app-environment: &app-environment
FFPROBE_PATH: /usr/bin/ffprobe
CHROMIUM_PATH: /usr/bin/chromium
RENDER_ORIGIN: http://web:3000
AUTH_CONFIG_FILE: ${AUTH_CONFIG_FILE:-}
AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-true}
x-app: &app
image: ${PROMPT_IMAGE:-prompt:local}
restart: unless-stopped
environment: *app-environment
init: true
volumes:
- auth_data:/auth:ro
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
@@ -47,6 +51,16 @@ services:
timeout: 5s
retries: 4
start_period: 10s
auth-admin:
<<: *app
profiles: [tools]
restart: "no"
user: root
cap_drop: []
cap_add: [CHOWN, FOWNER, DAC_OVERRIDE]
volumes:
- auth_data:/auth
command: ["node", "auth-cli.cjs", "init"]
migrate:
<<: *app
profiles: [tools]
@@ -100,5 +114,6 @@ services:
volumes:
- objects:/data
volumes:
auth_data:
postgres_data:
objects:
+46
View File
@@ -0,0 +1,46 @@
# 动态码登录与 SSH 绑定
生产全站使用共享验证器的 6 位动态码登录,无账号密码。它是单因素 TOTP 登录,不是两个因素的 2FA。每 30 秒更新,允许前后一个时间步偏差。登录后签发 HS256 JWT,保存在 HttpOnly、SameSite=Lax Cookie 中,有效期 7 天,不放 localStorage。
## 管理员扫码
SSH 登录部署服务器,在交互终端执行(不要放到 CI、日志或重定向到文件):
```sh
sudo /opt/prompt/auth qr
```
手机用 Google Authenticator、Microsoft Authenticator 或其他 TOTP 验证器扫描终端二维码。多人可以分别扫同一张二维码;动态码相同,不能区分操作人。不要把二维码发到公开聊天或网页。二维码不通过任何 HTTP 接口提供,也不写入源码或镜像;服务器 Docker 卷 `prompt_auth_data` 持久保存密钥,应用只读挂载,配置文件权限 0600、目录 0700。更新和容器重启不会更换二维码。
也可以从电脑直接执行(SSH 用户按自己的服务器配置替换):
```sh
ssh -t <服务器用户>@8.211.154.134 'sudo /opt/prompt/auth qr'
```
网站只需输入当前动态码。验证码成功使用后不能再次使用;多人同时登录时,后登录者需等下一组动态码。全站共用每 5 分钟最多 20 次尝试的限额,计数与已用时间步保存在 PostgreSQL 中,容器重启不会重置。
## 丢失手机、撤销访问或密钥泄漏
```sh
sudo /opt/prompt/auth rotate --confirm
sudo /opt/prompt/auth qr
```
轮换会同时使所有人的旧验证器及现有 JWT 失效,需要所有人重新扫码。恢复权限依赖服务器 SSH 管理权,没有公开重置入口。退出登录清除本机 Cookie;已复制的 JWT 在过期或密钥轮换前仍有效。
## 自动化和健康检查
除登录页、登录/退出接口、Next.js 代码资源及最小健康检查外,页面、数据接口、媒体、文档及 `/studio` 均要求登录。Studio 直连 18134 端口只绑定回环地址,不能绕过公网入口登录。登录 Cookie 的写入操作校验同源;API 可使用 `Authorization: Bearer <JWT>`。
服务器部署检查通过 `node auth-cli.cjs run <脚本路径>` 在进程内给本机请求加入短期 JWT,不打印令牌。人工自动化可通过 `sudo /opt/prompt/auth token` 在交互终端获取有效 1 小时的令牌,权限等同共享登录,请妥善保存。外部客户端需要自行携带令牌;已有匿名下载/发布脚本需要相应调整。
## HTTP 与 HTTPS
当前入口是 HTTP,为兼容现有地址,首次部署在 `/etc/prompt.env` 添加 `AUTH_COOKIE_SECURE=false`。这只能防止未授权访问,HTTP 仍可能被窃听。启用 HTTPS 后将其改为 `true` 并重新创建 web 容器,使 Cookie 带 Secure。应用默认 Secure;只有明确设置 false 才兼容 HTTP。代理环境必须保留真实 Host 和 Origin。
## CI、部署与回退
Docker CI 先完成原有集成测试,再启用独立测试密钥验证未登录拦截、真实动态码登录、并发重放拒绝、JWT/Cookie、跨站拒绝、限速以及重启后的访问保护。二维码不会输出到 CI 日志。生产部署自动初始化密钥、运行数据库迁移并验证访问保护;失败沿用部署脚本的镜像和数据库备份回退。认证配置损坏或丢失时返回 503,不会开放访问。
手动回退使用 `git revert <本次提交>` 后推送,等待 CI 部署。007 迁移仅新增认证计数表,不修改既有笔记与素材。`prompt_auth_data` 不应删除;回退旧版本将恢复原来的匿名访问行为。
+2 -2
View File
@@ -12,7 +12,7 @@ Node 24, pnpm 11.25.0, Python 3.12+. `pnpm install`; `pnpm dev:wiki`; `pnpm dev:
Three path-filtered Gitea workflows run on ces-control. Changes to one app trigger that app only. Workspace configuration changes intentionally verify both JS applications. Documentation changes do not deploy. Each workflow can be manually dispatched for recovery after a failed release. All app content, including Markdown, triggers its workflow.
Wiki keeps `/opt/prompt`, port 18131 and existing persistent volumes. Studio uses its own Compose project, `/opt/prompt-studio`, port 18134, and verifies its AI index before recording the release; firewall access to 18134 may need operator configuration. Python publishes `prompt-python:<sha>` and the local `latest` alias, used on demand rather than running an empty daemon. No Python network port is exposed. Root Dockerfile is Wiki's build entry; `apps/wiki/Dockerfile` mirrors it for discovery.
Wiki keeps `/opt/prompt`, port 18131 and existing persistent volumes. Studio uses its own Compose project, `/opt/prompt-studio`, port 18134, and verifies its AI index before recording the release; port 18134 is bound to loopback so access goes through Wiki authentication. Python publishes `prompt-python:<sha>` and the local `latest` alias, used on demand rather than running an empty daemon. No Python network port is exposed. Root Dockerfile is Wiki's build entry; `apps/wiki/Dockerfile` mirrors it for discovery.
CI uses root build context and application-scoped COPY instructions. Wiki runs its existing Docker integration suite before deploying. The former Web-only release path is retired because its source-copy and image assumptions predated the monorepo. No `down -v` is used in production.
@@ -22,7 +22,7 @@ CI uses root build context and application-scoped COPY instructions. Wiki runs i
`/api/preview` 只接受本站静态栅格图或媒体 ID,限制四档尺寸,输出 WebP:列表目标最多 100 KB、详情目标最多 220 KB。原图按需下载。服务端预览使用并发限制、请求合并、磁盘缓存和 ETag;缓存数量有限并定期清理。SVG 图标不经过栅格化。
Studio 部署到同一 Docker 网络,通过 `http://8.211.154.134:18131/studio` 访问,不依赖额外公网端口。直连端口是 `18134/studio`;只做辅助入口,公网可达性取决于服务器网络规则。
Studio 部署到同一 Docker 网络,通过 `http://8.211.154.134:18131/studio` 访问,不依赖额外公网端口。直连端口 `127.0.0.1:18134/studio` 仅服务器本机可用;公网访问统一经过 Wiki 登录保护。
Docker 在单独的依赖下载层执行 `pnpm fetch`,再离线安装。源码变化复用依赖层,包脚本变化复用已下载的包。CI 不清理 Docker 缓存,不使用 `--no-cache`。
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [ "$(id -u)" -ne 0 ]; then exec sudo bash "$0" "$@"; fi
command=${1:-qr}
cd /opt/prompt/current
compose(){ env -u AUTH_CONFIG_FILE -u AUTH_COOKIE_SECURE docker compose -p prompt --env-file /etc/prompt.env --env-file release.env -f compose.yml "$@"; }
case "$command" in
qr|token) compose exec web node auth-cli.cjs "$command" ;;
rotate)
if [ "${2:-}" != "--confirm" ]; then echo 'This invalidates all bound authenticators and JWT sessions. Use: auth rotate --confirm'; exit 1; fi
compose run --rm --no-deps auth-admin node auth-cli.cjs rotate --confirm
echo 'Rotation complete. Run auth qr to bind again.' ;;
verify) compose exec -T web node auth-cli.cjs verify ;;
*) echo 'Usage: auth [qr | token | verify | rotate --confirm]'; exit 1 ;;
esac
+1 -1
View File
@@ -12,7 +12,7 @@ if [ "$latest" != "$sha" ]; then
fi
mkdir -p /opt/prompt-studio
previous=$(cat /opt/prompt-studio/image 2>/dev/null || true)
write_compose(){ printf 'services:\n studio:\n image: %s\n restart: unless-stopped\n ports: ["18134:3000"]\n networks:\n wiki:\n aliases: [prompt-studio]\nnetworks:\n wiki:\n external: true\n name: prompt_default\n' "$1" > /opt/prompt-studio/compose.yml; }
write_compose(){ printf 'services:\n studio:\n image: %s\n restart: unless-stopped\n ports: ["127.0.0.1:18134:3000"]\n networks:\n wiki:\n aliases: [prompt-studio]\nnetworks:\n wiki:\n external: true\n name: prompt_default\n' "$1" > /opt/prompt-studio/compose.yml; }
write_compose "prompt-studio:$sha"
rollback(){ if [ -n "$previous" ]; then write_compose "$previous"; docker compose -p prompt-studio -f /opt/prompt-studio/compose.yml up -d; fi; }
trap rollback ERR
+8 -6
View File
@@ -17,7 +17,7 @@ test "$(git -c safe.directory="$PWD" rev-parse HEAD)" = "$RELEASE_SHA"
LATEST_MAIN=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1)
if [ "$LATEST_MAIN" != "$RELEASE_SHA" ]; then
git -c safe.directory="$PWD" fetch origin main
if ! git -c safe.directory="$PWD" diff --quiet "$RELEASE_SHA" origin/main -- apps/wiki Dockerfile compose.yml scripts/deploy.sh scripts/infra.sh scripts/integration.sh .gitea/workflows/wiki.yml pnpm-workspace.yaml; then
if ! git -c safe.directory="$PWD" diff --quiet "$RELEASE_SHA" origin/main -- apps/wiki Dockerfile compose.yml scripts/deploy.sh scripts/infra.sh scripts/integration.sh scripts/auth-admin.sh .gitea/workflows/wiki.yml pnpm-workspace.yaml; then
echo 'Skipping deployment: Wiki has a newer commit'; exit 0
fi
fi
@@ -26,8 +26,8 @@ if [ -L "$APP_ROOT/current" ] && [ -d "$APP_ROOT/current" ]; then PREVIOUS=$(rea
bash scripts/infra.sh "$IMAGE"
mkdir -p "$RELEASE_DIR"
install -m 0644 compose.yml "$RELEASE_DIR/compose.yml"
printf 'PROMPT_IMAGE=%s\nRELEASE_SHA=%s\nWEB_PORT=%s\n' "$IMAGE" "$RELEASE_SHA" "$PORT" > "$RELEASE_DIR/release.env"
compose_at(){ local release="$1"; shift; env -u PROMPT_IMAGE -u PROMPT_TEST_IMAGE -u RELEASE_SHA -u WEB_PORT -u WEB_BIND_ADDRESS docker compose -p prompt --env-file /etc/prompt.env --env-file "$release/release.env" -f "$release/compose.yml" "$@"; }
printf 'PROMPT_IMAGE=%s\nRELEASE_SHA=%s\nWEB_PORT=%s\nAUTH_CONFIG_FILE=/auth/config.json\n' "$IMAGE" "$RELEASE_SHA" "$PORT" > "$RELEASE_DIR/release.env"
compose_at(){ local release="$1"; shift; env -u AUTH_CONFIG_FILE -u AUTH_COOKIE_SECURE -u PROMPT_IMAGE -u PROMPT_TEST_IMAGE -u RELEASE_SHA -u WEB_PORT -u WEB_BIND_ADDRESS docker compose -p prompt --env-file /etc/prompt.env --env-file "$release/release.env" -f "$release/compose.yml" "$@"; }
compose(){ compose_at "$RELEASE_DIR" "$@"; }
LEGACY_WEB=0
LEGACY_WORKER=0
@@ -85,6 +85,7 @@ BACKUP_FILE="/var/lib/prompt/backups/$(date -u +%Y%m%dT%H%M%S)-$RELEASE_SHA.dump
compose exec -T postgres pg_dump -U prompt -d prompt -Fc > "$BACKUP_FILE"
find /var/lib/prompt/backups -name '*.dump' -printf '%T@ %p\n' | sort -rn | tail -n +11 | cut -d' ' -f2- | xargs -r rm --
MIGRATION_STARTED=1
compose run --rm --no-deps auth-admin
compose run --rm --no-deps migrate
compose run --rm --no-deps publish
CUTOVER=1
@@ -93,11 +94,11 @@ if [ "$LEGACY_WORKER" -eq 1 ] || [ "$LEGACY_WORKER_ENABLED" -eq 1 ]; then system
if [ "$LEGACY_WEB" -eq 1 ] || [ "$LEGACY_WEB_ENABLED" -eq 1 ]; then systemctl disable --now prompt.service; fi
compose up -d --no-build --wait --wait-timeout 180 web worker
compose exec -T web node container-health.mjs
compose exec -T web node -e "fetch('http://127.0.0.1:3000/').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
compose exec -T web node scripts/import-character-packs.mjs
compose exec -T web node auth-cli.cjs verify
compose exec -T web node auth-cli.cjs run scripts/import-character-packs.mjs
compose exec -T worker sh -c 'test "$(id -u)" -ne 0 && ffmpeg -version >/dev/null'
# Editorial posts may be older than the first 24 entries. Verify the complete paged feed.
compose exec -T web node - <<'NODE'
compose exec -T web sh -c 'cat > /tmp/prompt-release-check.mjs && node auth-cli.cjs run /tmp/prompt-release-check.mjs' <<'NODE'
(async()=>{
const slugs=['learning-skills-notes','learning-skills-teaching','learning-skills-english'];
const missing=new Set(slugs),seen=new Set();let cursor='';
@@ -118,6 +119,7 @@ NODE
ln -sfn "$RELEASE_DIR" "$APP_ROOT/.current.next"
mv -Tf "$APP_ROOT/.current.next" "$APP_ROOT/current"
install -m 0755 scripts/auth-admin.sh "$APP_ROOT/auth"
compose ps
for service in web worker postgres objects; do
id=$(compose ps -q "$service")
+5
View File
@@ -10,3 +10,8 @@ if [ ! -f /etc/prompt.env ]; then
mv "$TEMP_ENV" /etc/prompt.env
trap - EXIT
fi
# Current public entry uses HTTP. Operators can set true here after enabling HTTPS.
if ! grep -q '^AUTH_COOKIE_SECURE=' /etc/prompt.env; then
printf '\nAUTH_COOKIE_SECURE=false\n' >> /etc/prompt.env
fi
+9
View File
@@ -18,3 +18,12 @@ compose up -d --no-build --wait --wait-timeout 150 web worker
compose run --rm --no-deps tests
compose exec -T web node container-health.mjs
compose exec -T worker sh -c 'test "$(id -u)" -ne 0 && ffmpeg -version >/dev/null'
# Repeat route and login checks with production authentication enabled.
printf '\nAUTH_CONFIG_FILE=/auth/config.json\nAUTH_COOKIE_SECURE=false\n' >> "$TEST_ENV"
compose run --rm --no-deps auth-admin
compose up -d --no-build --wait --wait-timeout 150 web
compose exec -T web node auth-cli.cjs exercise
compose restart web
compose up -d --no-build --wait --wait-timeout 150 web
compose exec -T web node auth-cli.cjs verify