feat: bootstrap dodo phase one
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"dodo","short_name":"dodo","start_url":"/","display":"standalone","background_color":"#f8f3e8","theme_color":"#f15a29","lang":"zh-CN"}
|
||||
@@ -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)))})
|
||||
Reference in New Issue
Block a user