Skip to content

Commit 68f7125

Browse files
committed
Run entirely on Cloudflare and drop the Vercel layer
The site is one Worker: worker/index.js serves the built files through the ASSETS binding and handles /api/admin/*. Readers never reach it - the catalogue and every chapter are static objects on R2 behind the CDN - so the Worker exists for the admin panel, not for reading. Deleted: all eleven api/ functions, vercel.json, server/server.js, the three Blob modules, the blob-to-R2 migration script, and the Pages functions/ directory that a Workers project does not route. @vercel/blob and express are gone from package.json, which now has one runtime dependency. Handlers reuse the modules under server/ rather than reimplementing them, which nodejs_compat makes possible. That was worth verifying rather than assuming: workerd's scrypt, HMAC and base64url all produce byte-identical output to Node's, so the existing password hash stays valid and there is one implementation of signing, session verification and presigning instead of two. The duplicate Web Crypto signer I had written for Pages is therefore deleted. Two R2 bindings replace signing for reads and writes, so crawler config and the catalogue snapshot go through env.NOVEL_STORAGE / env.NOVEL_ARCHIVE. Presigning still uses the S3 credentials, because a binding cannot presign and a 200 MB EPUB must not pass through a Worker that caps request bodies at 100 MB. Behaviour changes forced by the removal, rather than chosen: - On-demand translation is gone. It needed a server that could call Gemini; translation now happens once at ingest, in Actions. The reader says so instead of failing on a dead endpoint. - The library no longer falls back to /api/library, so loadCatalogSnapshot is no longer gated on READER_CDN_ENABLED. That flag decides where chapters are read from; gating the catalogue on it left the shelf empty with the flag off. - Analytics inserts straight into Supabase with the anon key and has no fallback. - Admin catalogue and analytics need SUPABASE_SERVICE_ROLE_KEY on the Worker, and return an explicit 503 or an empty answer without it rather than failing obscurely. It is a Worker secret, never inlined into a bundle. - Per-IP login throttling is gone. Its counter was a module-level Map, which does nothing across isolates; the constant-time failure delay stays. Also fixed here: - The crawler no longer calls any website. SITE_URL, CRAWLER_SECRET, the OIDC token dance and the metadata fallback are all removed, which retires the JWKS rotation bug rather than working around it. - %CDN_ORIGIN% was substituted only when followed by a space, so removing the last source after it shipped the literal placeholder in img-src. The separator is now consumed with the token, checked with and without an origin. - The build prints which reader mode it produced. The flag is inlined, so nothing in the output showed it, and public/ is committed - it was possible to ship a CDN-enabled bundle by accident. This commit ships READER_CDN_ENABLED unset. - .wrangler/ and .dev.vars are ignored; local R2 state was staged for commit. Verified: clean npm install, build emits "(cdn: https://cdn.tram-chu.online)", wrangler reads 24 assets and bundles the Worker with no warnings, and workerd serves the real thing - wrong password 401, correct password 200 with a HttpOnly/Secure/SameSite=Strict cookie, presigned PUT into the private bucket with no secret in the URL, crawler config written to and read back from the R2 binding, ingest dispatch 503 without a token. 118 tests pass, and no secret appears in any browser bundle.
1 parent 9c68a22 commit 68f7125

54 files changed

Lines changed: 1222 additions & 4315 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
# Placeholders only. Real values live in .env.local (gitignored), in GitHub
2-
# Actions secrets for the workers, and in Cloudflare Pages env vars for the build.
2+
# Actions secrets for the workers, and in the Cloudflare Worker's variables and
3+
# secrets for the site.
34

4-
# ---- Gemini: translation at ingest time only. Never reaches the browser. -----
5+
# ---- Gemini: translation happens at ingest time, in GitHub Actions ----------
6+
# Never reaches the browser and is not configured on Cloudflare.
57
GEMINI_API_KEY=your_key
68
GEMINI_MODEL=gemini-3.1-flash-lite
79
GEMINI_FALLBACK_MODELS=gemini-3.5-flash-lite,gemini-3.6-flash
810
GEMINI_CHUNK_SIZE=4000
911
GEMINI_TRANSLATE_CONCURRENCY=1
1012

1113
# ---- Cloudflare R2 -----------------------------------------------------------
12-
# Public reader bucket: chapters, covers, catalog snapshot.
14+
# Public reader bucket: chapters, covers, catalogue snapshot.
1315
R2_ACCOUNT_ID=your_account_id
1416
R2_ACCESS_KEY_ID=your_access_key_id
1517
R2_SECRET_ACCESS_KEY=your_secret_access_key
1618
R2_BUCKET=novel-storage
17-
# Private bucket for source EPUBs. Must NOT be publicly reachable.
19+
# Private bucket: source EPUBs and crawler state. Must not be publicly reachable.
1820
R2_ARCHIVE_BUCKET=novel-archive
1921
# The only R2 value the browser is allowed to know.
2022
R2_PUBLIC_BASE_URL=https://cdn.example.com
@@ -26,30 +28,37 @@ SUPABASE_URL=https://your-project.supabase.co
2628
# Browser-safe. RLS restricts it to reading published metadata and inserting
2729
# analytics events.
2830
SUPABASE_ANON_KEY=your_publishable_key
29-
# Server-side only: workers, ingest, admin. NEVER in a client bundle.
31+
# Server-side only: the ingest and translation workers. NEVER in a browser bundle.
3032
SUPABASE_SERVICE_ROLE_KEY=your_secret_key
3133

34+
# ---- Admin (Cloudflare Worker) ----------------------------------------------
35+
# Generate with: npm run setup:admin
36+
LIBRARY_UPLOAD_PASSWORD_HASH=scrypt$salt$hash
37+
# Signs the admin session cookie. Any random value; changing it signs everyone out.
38+
LIBRARY_SESSION_SECRET=random_secret
39+
# Fine-grained PAT, Actions: read and write, this repository only. Lets the admin
40+
# page start an ingest run.
41+
GITHUB_DISPATCH_TOKEN=github_pat_xxx
42+
GITHUB_REPOSITORY=owner/repo
43+
3244
# ---- Reader feature flag ------------------------------------------------------
3345
# Chapters are read from the CDN only when this is true AND R2_PUBLIC_BASE_URL is
34-
# set. Leave false in production until preview validation is done.
35-
READER_CDN_ENABLED=false
46+
# set. Leave unset until the CDN reader has been validated by hand.
47+
# READER_CDN_ENABLED=true
3648

3749
# ---- Translation worker tuning ------------------------------------------------
38-
# Measured: ~17.8s latency per chapter, ~3.7 Gemini req/min on one thread.
50+
# Measured: ~17.8s latency per chapter, ~3.7 Gemini requests per minute on one
51+
# thread.
3952
TRANSLATE_SPACING_MS=1000
4053
TRANSLATE_BUDGET=150
4154
TRANSLATE_RUN_MINUTES=300
55+
# How often to republish index.json mid-run so readers see progress.
56+
TRANSLATE_PUBLISH_EVERY=25
4257

4358
# ---- Ingest ------------------------------------------------------------------
4459
INGEST_TRANSLATE=false
4560
INGEST_TRANSLATE_BUDGET=0
4661

47-
# ---- Legacy Vercel (fallback only during migration) --------------------------
48-
BLOB_READ_WRITE_TOKEN=legacy_blob_token
49-
LIBRARY_UPLOAD_PASSWORD_HASH=scrypt$salt$hash
50-
LIBRARY_SESSION_SECRET=random_secret
51-
CRAWLER_SECRET=optional_when_not_using_oidc
52-
5362
# ---- Local development -------------------------------------------------------
5463
# With no R2_* credentials the storage layer writes here instead, so the whole
5564
# ingest pipeline runs offline.

.github/workflows/fanqie-crawler.yml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,11 @@ jobs:
2525
runs-on: ubuntu-latest
2626
timeout-minutes: 330
2727
env:
28-
SITE_URL: https://auto-translate-xi.vercel.app
2928
TOMATO_PASSWORD: tomato-local-worker
3029
TOMATO_DATA_DIR: ${{ github.workspace }}/.crawler-data
31-
# The crawler now writes chapters to R2 and metadata to Supabase through
32-
# runIngest(), so it needs the same credentials as the translation worker.
33-
# Without these the ingest step fails at the first storage write.
30+
# The crawler writes chapters to R2 and metadata to Supabase through
31+
# runIngest(), and reads its own config and status from R2. It makes no
32+
# request to any website, so there is no site URL or token here.
3433
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
3534
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
3635
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
@@ -39,8 +38,8 @@ jobs:
3938
R2_PUBLIC_BASE_URL: ${{ secrets.R2_PUBLIC_BASE_URL }}
4039
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
4140
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
42-
# Only used to translate book title/author/description, which is a handful
43-
# of calls. Chapter translation is the separate worker's job.
41+
# Only translates title, author and description, a handful of calls.
42+
# Chapter bodies are the translation worker's job.
4443
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
4544
GEMINI_MODEL: ${{ secrets.GEMINI_MODEL }}
4645
GEMINI_FALLBACK_MODELS: ${{ secrets.GEMINI_FALLBACK_MODELS }}

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,10 @@ public/index.html
1616
public/vendor/
1717
.storage/
1818
.storage-e2e/
19+
20+
# wrangler local secrets
21+
.dev.vars
22+
.dev.vars.*
23+
24+
# wrangler local state, cache and bundles
25+
.wrangler/

README.md

Lines changed: 44 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export GEMINI_API_KEY=your_key
3434
Chạy app:
3535

3636
```bash
37-
npm start
37+
npm run dev
3838
```
3939

4040
Mở:
@@ -123,63 +123,75 @@ GEMINI_API_KEY=your_key
123123
Build step không cần. Start command:
124124

125125
```bash
126-
npm start
126+
npm run dev
127127
```
128128

129-
## Deploy Vercel
129+
## Deploy Cloudflare
130130

131-
Project đã có `api/translate.js` để chạy trên Vercel Serverless Functions.
131+
Toàn bộ site là một Cloudflare Worker: `worker/index.js` phục vụ file tĩnh qua
132+
binding `ASSETS` và xử lý các route `/api/admin/*`. Người đọc không chạm Worker —
133+
catalogue và mọi chapter là object tĩnh trên R2 do CDN phục vụ.
132134

133-
Vercel settings:
135+
Cấu hình build:
134136

135137
```text
136-
Framework Preset: Other
137-
Build Command: npm run build
138-
Output Directory: public
139-
Install Command: npm install
138+
Build command : npm run build
139+
Deploy command : npx wrangler deploy
140140
```
141141

142-
Environment Variables trên Vercel:
142+
`wrangler.toml` khai báo `main`, `[assets]` và hai R2 binding. Danh sách biến đầy
143+
đủ, kèm cái nào là secret và cái nào build cần, nằm ngay trong file đó.
144+
145+
Ba biến được inline vào bundle browser nên **phải có lúc build**, không chỉ lúc
146+
chạy: `R2_PUBLIC_BASE_URL`, `SUPABASE_URL`, `SUPABASE_ANON_KEY`. Log build sẽ in
143147

144148
```text
145-
GEMINI_API_KEY=your_key
146-
GEMINI_MODEL=gemini-3.1-flash-lite
147-
GEMINI_FALLBACK_MODELS=gemini-3.5-flash-lite,gemini-3.6-flash
148-
GEMINI_CHUNK_SIZE=4000
149-
GEMINI_TRANSLATE_CONCURRENCY=1
149+
/_headers 2.1 KB (cdn: https://cdn.tram-chu.online)
150150
```
151151

152-
## Upload EPUB không cần database
152+
Nếu thấy `(chưa có CDN origin)` thì `R2_PUBLIC_BASE_URL` chưa tới được bước build.
153+
154+
`READER_CDN_ENABLED` để **trống** cho tới khi đường đọc CDN được kiểm tra tay.
155+
156+
Chạy thử đúng runtime production ở local:
157+
158+
```bash
159+
npm run dev # wrangler dev, chạy workerd thật
160+
```
153161

154-
Thư viện dùng Vercel Blob. Gói Hobby có thể dùng miễn phí trong hạn mức của Vercel; file EPUB được upload thẳng từ trình duyệt lên Blob nên không đi qua giới hạn dung lượng request của Function.
162+
## Quản trị và upload EPUB
155163

156-
1. Trong Vercel Dashboard, mở project, vào `Storage` > `Create Database` > `Blob`, rồi kết nối store với project. Vercel sẽ tự thêm `BLOB_READ_WRITE_TOKEN`.
157-
2. Sinh hash mật khẩu và khóa phiên ở local:
164+
1. Sinh hash mật khẩu và khóa phiên ở local:
158165

159166
```powershell
160167
$env:ADMIN_PASSWORD="mat-khau-quan-tri"
161168
npm run setup:admin
162169
Remove-Item Env:ADMIN_PASSWORD
163170
```
164171

165-
3. Mở `.env`, đưa hai giá trị sau vào Vercel `Settings` > `Environment Variables` cho Production, Preview và Development:
172+
2. Đặt `LIBRARY_UPLOAD_PASSWORD_HASH``LIBRARY_SESSION_SECRET` làm secret của
173+
Worker. Đổi `LIBRARY_SESSION_SECRET` sẽ đăng xuất mọi phiên đang mở.
166174

167-
```text
168-
LIBRARY_UPLOAD_PASSWORD_HASH
169-
LIBRARY_SESSION_SECRET
170-
```
175+
3. Nút hình khóa trên thanh đầu trang mở khu vực quản trị.
171176

172-
4. Redeploy project. Nút hình khóa trên thanh đầu trang mở khu vực quản trị.
177+
EPUB **không** đi qua Worker. Cloudflare giới hạn body request 100 MB còn EPUB có
178+
thể 200 MB, nên Worker chỉ cấp một URL `PUT` có chữ ký ngắn hạn (30 phút) và trình
179+
duyệt đẩy file thẳng lên bucket private `novel-archive`. Sau đó Worker gọi
180+
`workflow_dispatch` để GitHub Actions ingest — việc đó mất nhiều phút, quá lâu cho
181+
bất kỳ request nào.
173182

174-
Mật khẩu không được ghi vào source. Server chỉ giữ hash `scrypt`, phiên quản trị nằm trong cookie `HttpOnly`, hết hạn sau 30 phút và endpoint upload kiểm tra quyền lại trước khi cấp token Blob. Mã HTML/CSS/JS gửi tới trình duyệt luôn có thể xem bằng DevTools; không đặt secret hay quyền ghi trong mã frontend.
183+
Mật khẩu không nằm trong source: server chỉ giữ hash `scrypt`, phiên nằm trong
184+
cookie `HttpOnly; Secure; SameSite=Strict` hết hạn sau 30 phút, và mọi route admin
185+
kiểm tra lại quyền cùng same-origin trước khi làm gì. Mã gửi tới browser luôn xem
186+
được bằng DevTools; không đặt secret nào trong đó.
175187

176188
## Fanqie crawler tự động
177189

178-
Crawler chạy bằng GitHub Actions mỗi 15 phút, 24/7, lấy book ID từ bảng xếp hạng Fanqie, dùng Tomato Novel Downloader để tạo EPUB, rồi upload EPUB và ảnh bìa vào Vercel Blob. Không cần VPS và không cần nhập link thủ công.
190+
Crawler chạy bằng GitHub Actions mỗi 15 phút, 24/7, lấy book ID từ bảng xếp hạng Fanqie, dùng Tomato Novel Downloader để tạo EPUB, rồi ingest thẳng vào R2 và Supabase. Không cần VPS và không cần nhập link thủ công.
179191

180-
Workflow không cần GitHub secret: nó dùng GitHub OIDC token ngắn hạn, bị giới hạn cho đúng repo `PhucPM89/AutoTranslate`, nhánh `main` và file workflow crawler. Sau khi deploy, đăng nhập khu vực quản trị, mở tab `Crawler`, chọn thể loại và bật tự động. Có thể chạy ngay workflow `Fanqie crawler` bằng nút `Run workflow`; lịch mặc định là phút 07, 22, 37 và 52 mỗi giờ.
192+
Worker crawler đọc config và ghi trạng thái trực tiếp trên R2, không gọi website nào, nên không cần token phiên. Sau khi deploy, đăng nhập khu vực quản trị, mở tab `Crawler`, chọn thể loại và bật tự động — hoặc dùng `node scripts/crawler-config.js --enable`. Có thể chạy ngay workflow `Fanqie crawler` bằng nút `Run workflow`; lịch mặc định là phút 07, 22, 37 và 52 mỗi giờ.
181193

182-
Worker ưu tiên cập nhật truyện Fanqie đã quá 24 giờ chưa đồng bộ; nếu lượt cập nhật đó không thêm được gì thì worker vẫn tiếp tục tìm truyện mới trong cùng lượt. File tải tạm chỉ nằm trong cache GitHub Actions, còn thư viện chính nằm trên Vercel Blob.
194+
Worker ưu tiên cập nhật truyện Fanqie đã quá 24 giờ chưa đồng bộ; nếu lượt cập nhật đó không thêm được gì thì worker vẫn tiếp tục tìm truyện mới trong cùng lượt. File tải tạm chỉ nằm trong cache GitHub Actions, còn thư viện chính nằm trên R2.
183195

184196
### Tìm truyện dài
185197

@@ -202,37 +214,17 @@ Nếu API thư viện lỗi, worker tự chuyển sang quét bảng xếp hạng
202214

203215
Truyện vài nghìn chương cần nhiều giờ để tải, nên worker được thiết kế để chạy dài:
204216

205-
- **Token OIDC được làm mới trong lúc chạy.** Token của GitHub chỉ sống khoảng 5 phút; trước đây worker lấy một lần lúc khởi động rồi dùng lại cho mọi lần gọi `updateStatus`, nên mọi lượt tải dài đều chết ở phút thứ 5. Giờ token tự làm mới trước khi hết hạn và thử lại một lần nếu gặp 401.
217+
- **Không còn phụ thuộc website.** Trạng thái được ghi thẳng lên R2. Trước đây worker gọi API của site bằng token OIDC sống ~5 phút, nên mọi lượt tải dài đều chết ở phút thứ 5; và khi storage của site ngừng hoạt động thì mọi lượt đều thất bại.
206218
- **Cache của Tomato luôn được lưu.** `actions/cache` chỉ lưu khi job thành công, tức là đúng những lượt tải dở lại bị mất sạch. Workflow tách thành `cache/restore``cache/save` với `if: always()`.
207219
- **Lượt sau tải tiếp đúng truyện đó.** Nếu một lượt chết giữa lúc tải, `currentBookId` được giữ lại trong trạng thái và lượt kế tiếp tải tiếp truyện đó trước, tối đa 3 lần rồi mới bỏ qua.
208220
- **Ngân sách thời gian.** Mặc định mỗi lượt làm việc tối đa 300 phút (`CRAWLER_RUN_BUDGET_MINUTES`), trong khi job cho phép 330 phút. Worker dừng chủ động khi gần hết ngân sách để còn kịp upload, publish và lưu cache; nó cũng không bắt đầu một truyện mới khi còn dưới 20 phút.
209221

210222
Repo đang là public nên GitHub Actions không giới hạn số phút. Lịch 15 phút vẫn giữ nguyên: nhờ `concurrency` group, lượt mới sẽ chờ lượt đang chạy kết thúc rồi khởi động gần như ngay lập tức, nên không còn khoảng trống 15 phút giữa các lần tải.
211223

212-
## Giới hạn 12 Serverless Function
213-
214-
Vercel Hobby chỉ cho phép **12 serverless function mỗi deployment**, và project đang dùng đúng 12. Thêm một file `.js` mới vào `api/` sẽ làm deploy thất bại với lỗi:
215-
216-
```text
217-
No more than 12 Serverless Functions can be added to a Deployment on the Hobby plan.
218-
```
219-
220-
Vì vậy vài endpoint được gộp chung một function, phân nhánh theo HTTP method:
221-
222-
```text
223-
api/analytics.js POST = beacon công khai từ trình duyệt người đọc
224-
GET = số liệu cho admin (cần phiên quản trị)
225-
api/admin/login.js GET = kiểm tra phiên
226-
POST = đăng nhập
227-
DELETE = đăng xuất
228-
```
229-
230-
`vercel.json` giữ rewrite cho các path cũ (`/api/admin/logout`, `/api/admin/analytics`) để trình duyệt còn giữ bundle cũ không bị lỗi. Khi cần thêm endpoint mới, hãy gộp vào function sẵn có thay vì tạo file mới.
231-
232224
## Số liệu người đọc
233225

234226
Tab `Số liệu` trong khu quản trị hiển thị lượt truy cập và lượt mở truyện theo hôm nay / 7 ngày / 30 ngày / tổng cộng, kèm danh sách truyện được mở nhiều nhất.
235227

236-
Cách đếm được thiết kế cho hạn mức miễn phí: trình duyệt chỉ gửi beacon tới `/api/analytics` **một lần mỗi phiên****một lần cho mỗi truyện được mở**, chứ không phải mỗi lần đổi trang. Một người đọc vì vậy chỉ tốn một hai lần gọi function.
228+
Cách đếm được thiết kế cho hạn mức miễn phí: trình duyệt insert thẳng vào Supabase bằng khóa anon **một lần mỗi phiên****một lần cho mỗi truyện được mở**, chứ không phải mỗi lần đổi trang. Không có function nào được gọi. RLS cho phép anon insert `analytics_events` và không cho đọc lại, sửa hay xoá bất cứ thứ gì.
237229

238-
Số liệu nằm trong `library/analytics.json` trên Vercel Blob, giữ 60 ngày gần nhất. Không lưu IP, cookie hay bất kỳ danh tính nào, nên con số là **số phiên truy cập** chứ không phải số người chính xác.
230+
Số liệu nằm ở bảng `analytics_events` trên Supabase và được đọc qua view tổng hợp `analytics_daily`. Không lưu IP, cookie hay bất kỳ danh tính nào — chỉ một id phiên ngẫu nhiên trong `sessionStorage` nên con số là **số phiên truy cập** chứ không phải số người chính xác.

0 commit comments

Comments
 (0)