feat: bootstrap dodo phase one

This commit is contained in:
2026-09-05 10:47:44 +08:00
commit f182c704a4
48 changed files with 4221 additions and 0 deletions
View File
+65
View File
@@ -0,0 +1,65 @@
import hashlib
import secrets
from datetime import UTC, datetime, timedelta
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from fastapi import Cookie, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .config import get_settings
from .db import get_db
from .models import Session, User
password_hasher = PasswordHasher()
COOKIE_NAME = "dodo_session"
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def hash_password(password: str) -> str:
return password_hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
return password_hasher.verify(password_hash, password)
except (InvalidHashError, VerificationError):
return False
async def issue_session(db: AsyncSession, response: Response, user: User) -> None:
token = secrets.token_urlsafe(32)
expires = datetime.now(UTC) + timedelta(days=get_settings().session_days)
db.add(Session(token_hash=hash_token(token), user_id=user.id, expires_at=expires))
await db.commit()
response.set_cookie(
COOKIE_NAME, token, max_age=get_settings().session_days * 86400,
httponly=True, secure=get_settings().cookie_secure, samesite="lax", path="/",
)
async def session_token(
token: str | None = Cookie(default=None, alias=COOKIE_NAME),
) -> str:
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录")
return token
async def current_user(
token: str = Depends(session_token),
db: AsyncSession = Depends(get_db),
) -> User:
result = await db.execute(
select(User).join(Session).where(
Session.token_hash == hash_token(token), Session.expires_at > datetime.now(UTC)
)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已失效,请重新登录")
return user
+19
View File
@@ -0,0 +1,19 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "dodo"
database_url: str = "postgresql+asyncpg://dodo:dodo@localhost:5432/dodo"
session_days: int = 30
cookie_secure: bool = False
trusted_proxies: str = ""
auto_create_schema: bool = False
model_config = SettingsConfigDict(env_prefix="DODO_", env_file=".env", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()
+41
View File
@@ -0,0 +1,41 @@
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from .config import get_settings
class Base(DeclarativeBase):
pass
_engine = None
_session_factory = None
def get_engine():
global _engine, _session_factory
if _engine is None:
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
return _engine
def reset_engine() -> None:
global _engine, _session_factory
_engine = None
_session_factory = None
async def get_db() -> AsyncIterator[AsyncSession]:
get_engine()
assert _session_factory is not None
async with _session_factory() as session:
yield session
async def create_schema() -> None:
from . import models # noqa: F401
async with get_engine().begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+218
View File
@@ -0,0 +1,218 @@
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import UUID
from fastapi import Depends, FastAPI, HTTPException, Response
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from .auth import (
COOKIE_NAME,
current_user,
hash_password,
hash_token,
issue_session,
session_token,
verify_password,
)
from .db import create_schema, get_db
from .models import AppState, Folder, Session, Task, TaskList, User, utcnow
from .schemas import (
FolderCreate,
FolderOut,
InitializeRequest,
ListCreate,
ListOut,
LoginRequest,
TaskCreate,
TaskOut,
TaskPage,
TaskUpdate,
UserOut,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
from .config import get_settings
if get_settings().auto_create_schema:
await create_schema()
yield
app = FastAPI(title="dodo", version="0.1.0", lifespan=lifespan, docs_url="/api/docs", openapi_url="/api/openapi.json")
@app.get("/health/live")
async def live():
return {"status": "ok"}
@app.get("/health/ready")
async def ready(db: AsyncSession = Depends(get_db)):
await db.execute(select(1))
return {"status": "ok"}
@app.get("/api/v1/setup/status")
async def setup_status(db: AsyncSession = Depends(get_db)):
count = await db.scalar(select(func.count()).select_from(User))
return {"initialized": bool(count)}
@app.post("/api/v1/setup/initialize", response_model=UserOut, status_code=201)
async def initialize(payload: InitializeRequest, response: Response, db: AsyncSession = Depends(get_db)):
count = await db.scalar(select(func.count()).select_from(User))
if count:
raise HTTPException(status_code=409, detail="系统已经初始化")
db.add(AppState(key="initialized"))
user = User(username=payload.username, password_hash=hash_password(payload.password))
db.add(user)
try:
await db.flush()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="系统已经初始化") from exc
db.add(TaskList(user_id=user.id, name="收集箱", is_inbox=True))
await db.commit()
await db.refresh(user)
await issue_session(db, response, user)
return user
@app.post("/api/v1/auth/login", response_model=UserOut)
async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
user = await db.scalar(select(User).where(User.username == payload.username))
if user is None or not verify_password(user.password_hash, payload.password):
raise HTTPException(status_code=401, detail="用户名或密码错误")
await issue_session(db, response, user)
return user
@app.get("/api/v1/me", response_model=UserOut)
async def me(user: User = Depends(current_user)):
return user
@app.post("/api/v1/auth/logout", status_code=204)
async def logout(
response: Response,
token: str = Depends(session_token),
db: AsyncSession = Depends(get_db),
):
session = await db.scalar(select(Session).where(Session.token_hash == hash_token(token)))
if session is not None:
await db.delete(session)
await db.commit()
response.delete_cookie(COOKIE_NAME, path="/")
return Response(status_code=204, headers=response.headers)
@app.post("/api/v1/folders", response_model=FolderOut, status_code=201)
async def create_folder(payload: FolderCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
folder = Folder(user_id=user.id, name=payload.name)
db.add(folder); await db.commit(); await db.refresh(folder)
return folder
@app.get("/api/v1/folders", response_model=list[FolderOut])
async def list_folders(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
return list((await db.scalars(select(Folder).where(Folder.user_id == user.id).order_by(Folder.position, Folder.created_at))).all())
@app.post("/api/v1/lists", response_model=ListOut, status_code=201)
async def create_list(payload: ListCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
if payload.folder_id and not await db.scalar(select(Folder.id).where(Folder.id == payload.folder_id, Folder.user_id == user.id)):
raise HTTPException(status_code=404, detail="文件夹不存在")
item = TaskList(user_id=user.id, folder_id=payload.folder_id, name=payload.name)
db.add(item); await db.commit(); await db.refresh(item)
return item
@app.get("/api/v1/lists", response_model=list[ListOut])
async def list_lists(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
return list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id).order_by(TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at))).all())
@app.post("/api/v1/tasks", response_model=TaskOut, status_code=201)
async def create_task(payload: TaskCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
if not await db.scalar(select(TaskList.id).where(TaskList.id == payload.list_id, TaskList.user_id == user.id)):
raise HTTPException(status_code=404, detail="清单不存在")
if payload.parent_id:
parent = await db.scalar(
select(Task).where(
Task.id == payload.parent_id,
Task.user_id == user.id,
Task.list_id == payload.list_id,
Task.parent_id.is_(None),
Task.deleted_at.is_(None),
)
)
if parent is None:
raise HTTPException(status_code=400, detail="父任务必须属于同一清单")
task = Task(user_id=user.id, **payload.model_dump())
db.add(task); await db.commit(); await db.refresh(task)
return task
@app.get("/api/v1/tasks", response_model=TaskPage)
async def list_tasks(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
items = list((await db.scalars(select(Task).where(Task.user_id == user.id, Task.deleted_at.is_(None), Task.parent_id.is_(None)).order_by(Task.completed, Task.position, Task.created_at))).all())
return TaskPage(items=items)
@app.patch("/api/v1/tasks/{task_id}", response_model=TaskOut)
async def update_task(task_id: UUID, payload: TaskUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
data = payload.model_dump(exclude_unset=True)
expected_version = data.pop("version")
data["version"] = Task.version + 1
data["updated_at"] = utcnow()
result = await db.execute(
update(Task)
.where(
Task.id == task_id,
Task.user_id == user.id,
Task.deleted_at.is_(None),
Task.version == expected_version,
)
.values(**data)
.returning(Task)
)
task = result.scalar_one_or_none()
if task is None:
exists = await db.scalar(
select(Task.id).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None))
)
if exists:
raise HTTPException(status_code=409, detail="任务已被更新,请刷新后重试")
raise HTTPException(status_code=404, detail="任务不存在")
await db.commit()
return task
@app.delete("/api/v1/tasks/{task_id}", status_code=204)
async def delete_task(task_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
task = await db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None)))
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
task.deleted_at = utcnow()
task.version += 1
await db.commit()
return Response(status_code=204)
static_dir = Path(__file__).parent / "static"
if static_dir.exists():
app.mount("/assets", StaticFiles(directory=static_dir / "assets"), name="assets")
@app.get("/{path:path}", include_in_schema=False)
async def spa(path: str):
root = static_dir.resolve()
target = (root / path).resolve()
if target.is_file() and target.is_relative_to(root):
return FileResponse(target)
return FileResponse(root / "index.html")
+78
View File
@@ -0,0 +1,78 @@
from datetime import UTC, datetime
from uuid import UUID
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from uuid_utils import uuid7
from .db import Base
def new_id() -> UUID:
return UUID(str(uuid7()))
def utcnow() -> datetime:
return datetime.now(UTC)
class AppState(Base):
__tablename__ = "app_state"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class User(Base):
__tablename__ = "users"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(Text)
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Shanghai")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Session(Base):
__tablename__ = "sessions"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Folder(Base):
__tablename__ = "folders"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(120))
position: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class TaskList(Base):
__tablename__ = "task_lists"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
folder_id: Mapped[UUID | None] = mapped_column(ForeignKey("folders.id", ondelete="SET NULL"), nullable=True)
name: Mapped[str] = mapped_column(String(120))
is_inbox: Mapped[bool] = mapped_column(Boolean, default=False)
position: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Task(Base):
__tablename__ = "tasks"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
list_id: Mapped[UUID] = mapped_column(ForeignKey("task_lists.id", ondelete="CASCADE"), index=True)
parent_id: Mapped[UUID | None] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), nullable=True)
title: Mapped[str] = mapped_column(String(500))
description: Mapped[str] = mapped_column(Text, default="")
priority: Mapped[int] = mapped_column(Integer, default=0)
completed: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
version: Mapped[int] = mapped_column(Integer, default=1)
position: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+79
View File
@@ -0,0 +1,79 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class InitializeRequest(BaseModel):
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
password: str = Field(min_length=12, max_length=256)
class LoginRequest(BaseModel):
username: str
password: str
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
username: str
class FolderCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
class FolderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
class ListCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
folder_id: UUID | None = None
class ListOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
folder_id: UUID | None
name: str
is_inbox: bool
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=500)
list_id: UUID
description: str = ""
priority: int = Field(default=0, ge=0, le=3)
due_at: datetime | None = None
parent_id: UUID | None = None
class TaskUpdate(BaseModel):
title: str = Field(default=None, min_length=1, max_length=500)
description: str = Field(default=None)
priority: int | None = Field(default=None, ge=0, le=3)
due_at: datetime | None = None
completed: bool | None = None
version: int = Field(ge=1)
class TaskOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
list_id: UUID
parent_id: UUID | None
title: str
description: str
priority: int
completed: bool
due_at: datetime | None
version: int
class TaskPage(BaseModel):
items: list[TaskOut]
next_cursor: str | None = None
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-BHdY9q3o.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BBQtE9Jy.css">
</head><body><div id="app"></div></body></html>
+1
View File
@@ -0,0 +1 @@
{"name":"dodo","short_name":"dodo","start_url":"/","display":"standalone","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN"}
+1
View File
@@ -0,0 +1 @@
const CACHE='dodo-shell-v1';self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(['/','/manifest.json']))));self.addEventListener('activate',e=>e.waitUntil(self.clients.claim()));self.addEventListener('fetch',e=>{if(e.request.url.includes('/api/'))return;e.respondWith(caches.match(e.request).then(r=>r||fetch(e.request)))})