Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-30 11:44:37 +07:00
parent cd7b8179dd
commit 5da7cc4530
19 changed files with 6066 additions and 156 deletions
+10
View File
@@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
.git/
.gitignore
.DS_Store
node_modules/
.next/
+15
View File
@@ -0,0 +1,15 @@
# PostgreSQL + MongoDB
DATABASE_URL=postgresql://reader:reader@localhost:5432/reader
MONGODB_URI=mongodb://localhost:27017/reader
# Auth / OAuth
NEXTAUTH_SECRET=replace-with-strong-secret
MOBILE_JWT_SECRET=replace-with-strong-secret
# Comma-separated allowed Google OAuth client IDs (web + android if needed)
GOOGLE_CLIENT_ID=web-client-id.apps.googleusercontent.com,android-client-id.apps.googleusercontent.com
# CORS (comma-separated), * for all in local dev
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# Environment label
APP_ENV=development
+43
View File
@@ -0,0 +1,43 @@
name: Build and Push Docker Image
on:
push:
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
fevirtus/reader-api:latest
ghcr.io/fevirtus/reader-api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
+11 -9
View File
@@ -1,12 +1,14 @@
# v0 runtime files __pycache__/
__v0_runtime_loader.js *.pyc
__v0_devtools.tsx *.pyo
__v0_jsx-dev-runtime.ts *.pyd
.Python
.venv/
dist/
build/
# Common ignores
node_modules/
.next/
.env*.local
.DS_Store .DS_Store
.env .env
test-ebook/ .env.local
# Local debug/test scripts
test_*.py
+256
View File
@@ -0,0 +1,256 @@
# 🐛 Chapter Save Debugging Guide
## Vấn Đề Đã Fix
### ✅ Fix 1: Ownership Check (Line 932)
- **Vấn đề:** MOD không thể tạo chapter cho truyện mặc định (uploaderId = NULL)
- **Fix:** Thêm `OR uploaderId IS NULL` vào WHERE clause
- **Dòng:** 932, 1002
### ✅ Fix 2: Input Validation
- **Vấn đề:** Content có thể trống, number có thể âm
- **Fix:** Thêm validation trước insert
- **Dòng:** 920-927, 997-1004
### ✅ Fix 3: Data Consistency Logging
- **Vấn đề:** Nếu MongoDB insert succeed nhưng PostgreSQL fail → dữ liệu inconsistent
- **Fix:** Thêm separate error handling và logging [CRITICAL]
- **Dòng:** 956-974
---
## 🔍 Testing Checklist
### A. Network Debugging (Browser DevTools)
1. **Mở DevTools:** F12 → Network tab
2. **Ấn "Lưu chương"**
3. **Kiểm tra request `POST /api/mod/chuong`:**
- ✅ Status code: `201` = success, `4xx`/`5xx` = error
- ✅ Response body: Phần lấy `id`, `number`, `title`
- ❌ Status 403: Ownership issue
- ❌ Status 400: Duplicate or validation error
- ❌ Status 500: Server error (check [CRITICAL] logs)
### B. MongoDB Verification
```bash
# Access MongoDB
mongosh # hoặc mongo
# Switch to database
use reader_db # (thay bằng tên DB thực tế)
# List recent chapters
db.chapters.find({}, {novelId: 1, number: 1, title: 1, createdAt: 1})
.sort({_id: -1})
.limit(5)
# Check specific novel
db.chapters.countDocuments({novelId: "YOUR_NOVEL_ID"})
# Check for duplicates (race condition)
db.chapters.find({novelId: "YOUR_NOVEL_ID", number: 1})
```
### C. PostgreSQL Verification
```bash
# Access PostgreSQL
psql # hoặc your database client
# Check novel total chapters count
SELECT id, title, "totalChapters" FROM "Novel" WHERE id = 'YOUR_NOVEL_ID';
# Verify it matches MongoDB count
-- MongoDB should have same count as "totalChapters"
```
### D. Server Log Analysis
Look for these patterns in backend logs:
```
✅ Success:
[timestamp] POST /mod/chuong - Status 201
[timestamp] Inserted chapter id: xxx
❌ Issues:
[CRITICAL] ⚠️ INCONSISTENT STATE: Chapter inserted in MongoDB...
[timestamp] Lỗi MongoDB: [error message]
[timestamp] Ownership check failed: 403
```
---
## 🚀 Common Scenarios & Solutions
### Scenario 1: Network Shows 201 But Chapter Not Visible
**Cause:** Chapter saved but not refreshed in UI
**Solution:**
- Press F5 to refresh page
- Check MongoDB to confirm chapter exists
- Check if `fetchChapters()` was called after save
### Scenario 2: Network Shows 403 Forbidden
**Cause:** Novel ownership check failed
**Solution:**
- Verify you are MOD or ADMIN user
- Verify novel exists in PostgreSQL:
```sql
SELECT id, title, "uploaderId" FROM "Novel" WHERE id = 'YOUR_ID';
```
- If uploaderId is NULL (default), ensure you're MOD user
### Scenario 3: Network Shows 400 Bad Request
**Causes:**
- Chapter number already exists
- Title or content empty
- Chapter number ≤ 0
**Solution:** Check response detail message and fix input
### Scenario 4: Network Shows 500 Server Error
**Cause:** MongoDB or PostgreSQL failure
**Solution:**
- Check server logs for [CRITICAL] message
- If MongoDB failed: Check MongoDB connection
- If PostgreSQL failed: Check PostgreSQL connection
- Contact admin with error message
---
## 🔧 Advanced Debug Commands
### Check MongoDB Connection Status
```bash
# From backend terminal
python3 -c "
import asyncio
from app.database import mongo_db
async def check():
await mongo_db.command('ping')
print('✓ MongoDB Connected!')
asyncio.run(check())
"
```
### Check PostgreSQL Connection Status
```bash
# From backend terminal
python3 -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from app.database import SessionLocal
async def check():
async with SessionLocal() as session:
result = await session.execute('SELECT 1')
print('✓ PostgreSQL Connected!')
asyncio.run(check())
"
```
### Manually Sync Total Chapters
```bash
# If totalChapters is out of sync
mongosh
use reader_db
# Get count
db.chapters.countDocuments({novelId: "YOUR_NOVEL_ID"})
# Then update PostgreSQL manually:
# psql
UPDATE "Novel" SET "totalChapters" = 123 WHERE id = 'YOUR_NOVEL_ID';
```
---
## 📋 Test Cases
### Test 1: Basic Chapter Save
```
1. Create novel
2. Save chapter #1
3. ✅ Should appear in chapter list
4. ✅ totalChapters should be 1
```
### Test 2: Sequential Chapters
```
1. Save chapters 1, 2, 3
2. ✅ All should appear with correct numbers
3. ✅ Next chapter field should suggest 4
```
### Test 3: Duplicate Prevention
```
1. Save chapter #5
2. Try to save chapter #5 again
3. ✅ Should show "Chương 5 đã tồn tại"
```
### Test 4: Default Novel (MOD Permission)
```
1. Verify a novel with uploaderId = NULL exists
2. As MOD user, save chapter to that novel
3. ✅ Should succeed (not 403 Forbidden)
```
### Test 5: No Empty Content
```
1. Try to save chapter with empty title
2. ✅ Should show "Tiêu đề chương không được trống"
3. Try to save chapter with empty content
4. ✅ Should show "Nội dung chương không được trống"
```
---
## 🆘 Still Having Issues?
1. **Run checklist A, B, C above** and collect outputs
2. **Screenshot of Network tab response**
3. **MongoDB output from `find()`**
4. **Server log output (especially [CRITICAL] lines)**
5. Share these with: [your-dev-contact]
---
## 📊 Monitoring
### Health Check Script
```bash
#!/bin/bash
# save as monitor-save.sh
echo "=== Chapter Save System Health Check ==="
echo ""
echo "1. MongoDB Connection:"
# mongosh check here
echo ""
echo "2. PostgreSQL Connection:"
# psql check here
echo ""
echo "3. Backend API:"
curl -s http://localhost:8000/docs | head -20
echo ""
echo "=== Done ==="
```
Run: `bash monitor-save.sh`
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY pyproject.toml ./
RUN uv sync --no-dev
COPY app ./app
COPY prisma ./prisma
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+186
View File
@@ -0,0 +1,186 @@
# ✅ Chapter Save System - Fixed Issues Summary
**Date:** 2026-03-24
**Status:** All HIGH priority issues fixed ✅
---
## 🐛 Issues Identified & Fixed
### 1️⃣ **Ownership Check Bypass (HIGH)**
- **File:** `app/routers/mod.py`
- **Lines:** 932-936 (POST), 1000-1007 (PUT)
- **Issue:** MOD couldn't create chapters for default novels (uploaderId = NULL)
- **Fix:** Changed query from:
```python
WHERE id = :nid AND "uploaderId" = :uid
```
To:
```python
WHERE id = :nid AND ("uploaderId" = :uid OR "uploaderId" IS NULL)
```
- **Impact:** ✅ Fixed - MOD can now manage default novels
### 2️⃣ **Missing Input Validation (HIGH)**
- **File:** `app/routers/mod.py`
- **Lines:** 920-927 (POST), 997-1004 (PUT)
- **Issue:** Could save empty title/content, negative chapter numbers
- **Fix:** Added validation:
```python
if not body.title or not body.title.strip():
raise HTTPException(400, "Tiêu đề chương không được trống")
if body.number <= 0:
raise HTTPException(400, "Số chương phải > 0")
```
- **Impact:** ✅ Fixed - Invalid data rejected at API level
### 3️⃣ **Data Inconsistency on PostgreSQL Failure (HIGH)**
- **File:** `app/routers/mod.py`
- **Lines:** 956-974 (POST)
- **Issue:** If MongoDB insert succeeds but PostgreSQL sync fails → inconsistent state
- **Fix:** Added separate error handling:
```python
try:
result = await mongo_db.chapters.insert_one(doc)
except Exception as mongo_err:
raise HTTPException(500, f"Lỗi MongoDB: {str(mongo_err)}")
try:
total = await _sync_total_chapters(db, body.novelId)
except Exception as pg_err:
# Log [CRITICAL] and alert user
raise HTTPException(500, "Dữ liệu has được lưu nhưng...")
```
- **Impact:** ✅ Fixed - Clear error messages identify MongoDB vs PostgreSQL failures
### 4️⃣ **Frontend Error Display (MEDIUM)**
- **File:** `reader/app/mod/chuong/chapter-client.tsx`
- **Line:** 356
- **Issue:** Only checked `error` field, not FastAPI's `detail` field
- **Fix:** Changed:
```javascript
if (!res.ok) throw new Error(resData.error || resData.detail || "...")
```
- **Impact:** ✅ Fixed - Users see actual backend error messages
### 5️⃣ **Missing Novel Existence Check (MEDIUM)**
- **File:** `app/routers/mod.py`
- **Lines:** 948-951 (POST)
- **Issue:** Could try to save chapter for non-existent novel
- **Fix:** Added explicit check:
```python
novel_check = await db.execute(
text('SELECT id FROM "Novel" WHERE id = :nid'),
{"nid": body.novelId},
)
if not novel_check.first():
raise HTTPException(404, "Truyện không tồn tại")
```
- **Impact:** ✅ Fixed - Better error message (404 instead of 500)
---
## 📊 Testing Results
### Build Verification
```
✅ Python syntax: OK (py_compile passed)
✅ Next.js build: OK (all 11 routes successfully compiled)
✅ No type errors in modified files
```
### Modified Files
| File | Changes | Status |
|------|---------|--------|
| `app/routers/mod.py` | POST/PUT endpoints refactored with validation | ✅ Fixed |
| `reader/app/mod/chuong/chapter-client.tsx` | Error handling improved | ✅ Fixed |
| `CHAPTER_SAVE_DEBUG.md` | Debug guide created | ✅ New |
---
## 🚀 Next Steps for User
### ⚠️ IMPORTANT: Test the following scenarios
1. **Basic Save:** Create new novel → save Chapter 1 → verify appears in list
2. **Ownership:** Try saving to default novel as MOD user → should succeed now
3. **Duplicate:** Try saving same chapter twice → should show "đã tồn tại"
4. **Empty Content:** Try saving without title → should show validation error
5. **Negative Number:** Try chapter #-1 → should reject
### If Still Failing:
1. Open **DevTools Network tab** → F12 → Network
2. Try to save chapter
3. Look for **POST /api/mod/chuong** request
4. Check **Status code** and **Response body**
5. Use guide in `CHAPTER_SAVE_DEBUG.md` to troubleshoot
### Possible Remaining Issues
⚠️ **Not yet fixed (MEDIUM/LOW priority):**
- [ ] Race condition on duplicate chapter check (add MongoDB unique index)
- [ ] No MongoDB/PostgreSQL timeout configuration
- [ ] Generic exception handler logging (uses traceback.print_exc)
- [ ] Missing structured logging system
These are less critical but could cause issues under high load.
---
## 📝 Code Changes Summary
**Total lines modified:** ~150
**Files affected:** 2
**Breaking changes:** None (backward compatible)
**Rollback difficulty:** Low (simple validation additions)
---
## ✨ What Changed
```diff
# POST /mod/chuong
- Missing input validation
- Missing novel existence check
- Ownership query doesn't allow NULL uploaderId
- No separation of MongoDB vs PostgreSQL error handling
+ Full input validation (title, content, number)
+ Novel existence check with clear 404 error
+ Ownership check allows both user-owned and default novels
+ Separate error handling for MongoDB and PostgreSQL
+ Better error messages for debugging
+ Data consistency logging ([CRITICAL] alerts)
# PUT /mod/chuong
- Same issues as POST
+ Same fixes applied
# Frontend error handling
- Ignored FastAPI's 'detail' field
+ Now checks both 'error' and 'detail' fields
```
---
## 🔍 Monitoring Recommendations
1. **Set up log monitoring** for `[CRITICAL]` messages
2. **Verify MongoDB connection** on startup
3. **Verify PostgreSQL connection** on startup
4. **Add request logging** to track save operations
5. **Monitor totalChapters sync** for discrepancies
---
## 📞 Support
If issues persist after testing:
1. Follow debugging guide in `CHAPTER_SAVE_DEBUG.md`
2. Check Network tab for response codes
3. Verify MongoDB and PostgreSQL connectivity
4. Look for [CRITICAL] messages in server logs
5. Check browser console for JavaScript errors
+68 -147
View File
@@ -1,180 +1,101 @@
# reader-api # reader-api (FastAPI + UV)
Đây là backend API dùng chung cho cả web app `reader` và mobile app `reader-app`. Shared backend API for both:
- Web app: reader
- Mobile app: reader-app
## 🚀 Tính năng nổi bật This project is Python-first (FastAPI), with production-focused Docker setup and healthcheck.
- **Xác thực & Phân quyền**: Đăng nhập bằng Google Authentication (NextAuth). Hỗ trợ phân quyền người dùng (USER, MOD, ADMIN). ## Stack
- **Quản lý nội dung (Dành cho MOD/ADMIN)**: Dashboard quản lý truyện, tải lên chương mới, quản lý trạng thái truyện (Đang ra, Hoàn thành, Tạm ngưng).
- **Trải nghiệm đọc**: Khám phá truyện theo thể loại, tìm kiếm truyện, đọc chương truyện với hiệu suất cao (nội dung lưu ở MongoDB).
- **Tương tác người dùng**: Tính năng tủ sách (bookmark) giúp lưu lại tiến độ đọc, hỗ trợ bình luận ở truyện và từng chương.
## 🛠 Tech Stack - Python 3.11+
- FastAPI
- UV (package manager / runner)
- PostgreSQL (structured data)
- MongoDB (chapter content + user recommendations)
- **Framework**: [Next.js](https://nextjs.org/) (App Router), React 19 ## API Base URL
- **Styling**: [TailwindCSS v4](https://tailwindcss.com/) & [Radix UI](https://www.radix-ui.com/) (shadcn/ui)
- **Database Hybrid**:
- **PostgreSQL**: Lưu trữ dữ liệu cấu trúc (Tài khoản, Truyện, Thể loại, Bình luận, Tủ sách) thông qua **Prisma ORM**.
- **MongoDB**: Lưu trữ nội dung lớn (Chương truyện) thông qua **Mongoose**.
- **Auth**: [NextAuth.js](https://next-auth.js.org/)
--- - Local dev: http://localhost:8000
- Healthcheck: GET /api/health
## 💻 Hướng dẫn chạy Local (Phát triển) ## Environment
### 1. Yêu cầu cài đặt Create `.env` from `.env.example`.
- [Node.js](https://nodejs.org/) (Khuyến nghị bản LTS)
- [pnpm](https://pnpm.io/) (Tool quản lý package)
- Database: PostgreSQL và MongoDB đang chạy cục bộ hoặc trên máy chủ.
### 2. Cấu hình môi trường Required keys:
Tạo file `.env` ở thư mục gốc dựa trên `.env.example` (nếu có) hoặc điền các thông tin sau:
```env ```env
# URL kết nối PostgreSQL DATABASE_URL=postgresql://reader:reader@localhost:5432/reader
DATABASE_URL="postgresql://user:password@localhost:5432/reader?schema=public" MONGODB_URI=mongodb://localhost:27017/reader
NEXTAUTH_SECRET=replace-with-strong-secret
# URL kết nối MongoDB MOBILE_JWT_SECRET=replace-with-strong-secret
MONGODB_URI="mongodb://user:password@localhost:27017/reader?authSource=admin" # Comma-separated allowed Google OAuth client IDs
GOOGLE_CLIENT_ID=web-client-id.apps.googleusercontent.com,android-client-id.apps.googleusercontent.com
# Cấu hình NextAuth CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
NEXTAUTH_SECRET="your-super-secret-key" APP_ENV=development
NEXTAUTH_URL="http://localhost:3000"
# Cấu hình Google Login
GOOGLE_CLIENT_ID="your_google_client_id"
GOOGLE_CLIENT_SECRET="your_google_client_secret"
# AI Tool cho MOD (LLM + web search)
OPENAI_API_KEY="your_openai_api_key"
# Tùy chọn, mặc định: gpt-4o-mini-search-preview
OPENAI_WEB_MODEL="gpt-4o-mini-search-preview"
# Cloudflare R2 (lưu ảnh bìa)
R2_ACCOUNT_ID="your_cloudflare_account_id"
R2_ACCESS_KEY_ID="your_r2_access_key_id"
R2_SECRET_ACCESS_KEY="your_r2_secret_access_key"
R2_BUCKET_NAME="your_r2_bucket_name"
R2_PUBLIC_BASE_URL="https://your-public-r2-domain"
``` ```
### 3. Cài đặt dependencies và khởi tạo DB ## Dev Setup (UV)
1. Install UV
```bash ```bash
# Cài đặt các gói thư viện curl -LsSf https://astral.sh/uv/install.sh | sh
pnpm install
# Đồng bộ schema xuống PostgreSQL và generate Prisma client
npx prisma db push
# hoặc (nếu muốn dùng migrate)
# npx prisma migrate dev
# Generate thư viện Prisma
npx prisma generate
``` ```
### 4. Chạy môi trường phát triển 2. Sync dependencies
```bash ```bash
pnpm dev uv sync
``` ```
API chạy mặc định ở [http://localhost:3001](http://localhost:3001).
Ví dụ endpoint: [http://localhost:3001/api/novels/browse](http://localhost:3001/api/novels/browse).
--- 3. Run API in dev mode
## 🏗 Hướng dẫn Build
Để build project cho môi trường production:
```bash ```bash
# Đảm bảo Prisma Client đã được generate uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
npx prisma generate
# Chạy lệnh build của Next.js
pnpm build
``` ```
Sau khi build xong, bạn có thể khởi chạy server production bằng: 4. Verify health
```bash
pnpm start
```
---
## 🐳 Triển khai dưới dạng Docker
Bạn có thể dễ dàng triển khai ứng dụng bằng nền tảng Docker. Dưới đây là cách đóng gói và chạy thông qua `docker-compose`.
### 1. Tạo file `Dockerfile`
Tạo file `Dockerfile` ở thư mục gốc của dự án với cấu hình multi-stage build để tối ưu dung lượng:
```dockerfile
# Stage 1: Dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
# Stage 2: Builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Tạo prisma client
RUN npx prisma generate
# Chạy build
RUN corepack enable pnpm && pnpm build
# Stage 3: Runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
```
*(Lưu ý: Để build mục `standalone` hoạt động, bạn cần bổ sung `output: 'standalone'` trong file `next.config.mjs`)*
### 2. Tạo file `docker-compose.yml`
Sử dụng Docker Compose để chạy ứng dụng (giả sử Database của bạn được host riêng hoặc bạn có thể thêm service DB vào file này):
```yaml
version: '3.8'
services:
web:
build:
context: .
dockerfile: Dockerfile
image: reader-web:latest
container_name: reader-app
ports:
- "3000:3000"
env_file:
- .env
restart: unless-stopped
```
### 3. Khởi chạy bằng Docker
Chạy lệnh sau để build image và start container:
```bash ```bash
# Build và chạy ngầm (detached mode) curl http://localhost:8000/api/health
docker-compose up -d --build
``` ```
Để xem log của container: ## Docker Compose
### Production-style API only (external DBs)
```bash ```bash
docker-compose logs -f web docker compose up -d --build api
``` ```
Dừng và xóa container:
### Full local stack (API + Postgres + Mongo)
```bash ```bash
docker-compose down docker compose --profile localdb up -d --build
``` ```
## Implemented Endpoints
- GET /api/health
- POST /api/auth/mobile-login
- GET /api/user/profile
- GET/POST /api/user/bookmarks
- DELETE /api/user/bookmarks/{novelId}
- POST /api/user/reading-progress
- GET/POST /api/user/settings
- GET/POST/DELETE /api/user/recommendations
- GET /api/genres
- GET /api/novels/browse
- GET /api/novels/{idOrSlug}
- GET /api/truyen/{id}/chapters
- GET/POST /api/truyen/{id}/comments
- POST /api/truyen/{id}/rate
- GET /api/truyen/suggest
- GET /api/chapters/{chapterId}
## Notes
- Web session auth is supported via NextAuth session cookies (next-auth.session-token and secure variants).
- Mobile auth is supported via Bearer JWT from /api/auth/mobile-login.
View File
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
import datetime as dt
import os
from typing import Any
from fastapi import Depends, HTTPException, Request
from jose import JWTError, jwt
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db_session
SESSION_COOKIE_KEYS = [
"next-auth.session-token",
"__Secure-next-auth.session-token",
"authjs.session-token",
"__Secure-authjs.session-token",
]
def _jwt_secret() -> str:
return settings.mobile_jwt_secret or settings.nextauth_secret
def create_access_token(user_id: str) -> str:
now = dt.datetime.now(dt.timezone.utc)
payload = {
"sub": user_id,
"iat": int(now.timestamp()),
"exp": int((now + dt.timedelta(days=7)).timestamp()),
}
secret = _jwt_secret()
if not secret:
raise RuntimeError("Missing MOBILE_JWT_SECRET or NEXTAUTH_SECRET")
return jwt.encode(payload, secret, algorithm="HS256")
async def _get_user_by_id(db: AsyncSession, user_id: str) -> dict[str, Any] | None:
result = await db.execute(
text(
'SELECT id, email, name, image, role FROM "User" WHERE id = :user_id LIMIT 1'
),
{"user_id": user_id},
)
row = result.mappings().first()
return dict(row) if row else None
async def _get_user_from_session_cookie(db: AsyncSession, request: Request) -> dict[str, Any] | None:
token = None
for key in SESSION_COOKIE_KEYS:
value = request.cookies.get(key)
if value:
token = value
break
if not token:
return None
result = await db.execute(
text(
'SELECT u.id, u.email, u.name, u.image, u.role '
'FROM "Session" s '
'JOIN "User" u ON u.id = s."userId" '
'WHERE s."sessionToken" = :token AND s.expires > NOW() '
'LIMIT 1'
),
{"token": token},
)
row = result.mappings().first()
return dict(row) if row else None
async def resolve_current_user(db: AsyncSession, request: Request) -> dict[str, Any] | None:
auth = request.headers.get("authorization", "")
if auth.lower().startswith("bearer "):
token = auth.split(" ", 1)[1].strip()
secret = _jwt_secret()
if not secret:
return None
try:
payload = jwt.decode(token, secret, algorithms=["HS256"])
subject = payload.get("sub")
if isinstance(subject, str) and subject:
return await _get_user_by_id(db, subject)
except JWTError:
return None
return await _get_user_from_session_cookie(db, request)
async def require_current_user(db: AsyncSession, request: Request) -> dict[str, Any]:
user = await resolve_current_user(db, request)
if not user:
raise HTTPException(status_code=401, detail="Unauthorized")
return user
async def require_mod_user(request: Request, db: AsyncSession = Depends(get_db_session)) -> dict[str, Any]:
user = await resolve_current_user(db, request)
if not user:
raise HTTPException(status_code=401, detail="Unauthorized")
if user.get("role") not in ("MOD", "ADMIN"):
raise HTTPException(status_code=403, detail="Forbidden: MOD or ADMIN role required")
return user
+45
View File
@@ -0,0 +1,45 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "reader-api"
app_env: str = "development"
database_url: str
mongodb_uri: str
google_client_id: str = ""
nextauth_secret: str = ""
mobile_jwt_secret: str = ""
cors_origins: str = "*"
r2_account_id: str = ""
r2_access_key_id: str = ""
r2_secret_access_key: str = ""
r2_bucket_name: str = ""
r2_public_base_url: str = ""
deepseek_key: str = ""
deepseek_model: str = "deepseek-chat"
openrouter_key: str = ""
openrouter_paused: str = "true"
@property
def google_client_id_list(self) -> list[str]:
raw = (self.google_client_id or "").strip()
if not raw:
return []
return [item.strip() for item in raw.split(",") if item.strip()]
@property
def cors_origin_list(self) -> list[str]:
raw = (self.cors_origins or "*").strip()
if raw == "*":
return ["*"]
return [item.strip() for item in raw.split(",") if item.strip()]
settings = Settings()
+45
View File
@@ -0,0 +1,45 @@
from motor.motor_asyncio import AsyncIOMotorClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
def _normalize_database_url(url: str) -> str:
# Strip Prisma-only query params (e.g. ?schema=public) that asyncpg doesn't accept
from urllib.parse import urlparse, urlencode, parse_qsl, urlunparse
_PRISMA_ONLY_PARAMS = {"schema", "connection_limit", "pool_timeout", "connect_timeout", "sslmode"}
if url.startswith("postgresql+asyncpg://"):
scheme = "postgresql+asyncpg"
elif url.startswith("postgresql://") or url.startswith("postgres://"):
scheme = "postgresql+asyncpg"
else:
return url
parsed = urlparse(url)
clean_params = [(k, v) for k, v in parse_qsl(parsed.query) if k not in _PRISMA_ONLY_PARAMS]
clean_url = urlunparse((
scheme,
parsed.netloc,
parsed.path,
parsed.params,
urlencode(clean_params),
parsed.fragment,
))
return clean_url
engine = create_async_engine(_normalize_database_url(settings.database_url), pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
mongo_client = AsyncIOMotorClient(settings.mongodb_uri)
mongo_db = mongo_client.get_default_database()
async def get_db_session() -> AsyncSession:
session = SessionLocal()
try:
yield session
finally:
await session.close()
+1202
View File
File diff suppressed because it is too large Load Diff
View File
+2417
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
services:
api:
build:
context: .
dockerfile: Dockerfile
image: reader-api:latest
container_name: reader-api
env_file:
- .env
ports:
- "8000:8000"
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health').read()"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
depends_on:
postgres:
condition: service_healthy
mongo:
condition: service_healthy
postgres:
image: postgres:16-alpine
container_name: reader-api-postgres
profiles: ["localdb"]
environment:
POSTGRES_DB: reader
POSTGRES_USER: reader
POSTGRES_PASSWORD: reader
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U reader -d reader"]
interval: 10s
timeout: 5s
retries: 10
mongo:
image: mongo:7
container_name: reader-api-mongo
profiles: ["localdb"]
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
volumes:
postgres_data:
mongo_data:
+84
View File
@@ -0,0 +1,84 @@
{
"name": "reader-api",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"lint": "eslint ."
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@aws-sdk/client-s3": "^3.1006.0",
"@hookform/resolvers": "^3.9.1",
"@prisma/client": "^5.22.0",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-aspect-ratio": "1.1.8",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-context-menu": "2.2.16",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "1.1.15",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-menubar": "1.1.16",
"@radix-ui/react-navigation-menu": "1.2.14",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@vercel/analytics": "1.6.1",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"date-fns": "4.1.0",
"embla-carousel-react": "8.6.0",
"epub2": "^3.0.2",
"html-to-text": "^9.0.5",
"input-otp": "1.4.2",
"lucide-react": "^0.564.0",
"mammoth": "^1.11.0",
"mongoose": "^9.2.4",
"next": "16.1.6",
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
"prisma": "^5.22.0",
"react": "19.2.4",
"react-day-picker": "9.13.2",
"react-dom": "19.2.4",
"react-hook-form": "^7.54.1",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.0",
"sonner": "^1.7.1",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.0",
"@types/html-to-text": "^9.0.4",
"@types/node": "^22",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"dotenv": "^17.3.1",
"pg": "^8.20.0",
"postcss": "^8.5",
"tailwindcss": "^4.2.0",
"tw-animate-css": "1.3.3",
"typescript": "5.7.3"
}
}
+39
View File
@@ -0,0 +1,39 @@
[project]
name = "reader-api"
version = "0.1.0"
description = "Shared backend API for reader web and reader-app mobile clients"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.116.1",
"uvicorn[standard]>=0.35.0",
"sqlalchemy[asyncio]>=2.0.43",
"greenlet>=3.0.0",
"asyncpg>=0.30.0",
"motor>=3.7.1",
"python-jose[cryptography]>=3.5.0",
"google-auth>=2.40.3",
"requests>=2.32.5",
"pydantic-settings>=2.10.1",
# mod routes deps
"boto3>=1.35.0",
"httpx>=0.27.0",
"ebooklib>=0.18",
"html2text>=2024.2.26",
"python-slugify>=8.0.4",
"python-multipart>=0.0.9",
]
[dependency-groups]
dev = [
"ruff>=0.12.11",
]
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.uv]
package = false
Generated
+1461
View File
File diff suppressed because it is too large Load Diff