Compare commits

..

20 Commits

Author SHA1 Message Date
Zayden-Jung 61e3d71b72 make a media api, encapsulate the original S3 bucket address
there are 3 parts, from back to head, url inside the bucket -> url of backend media api -> backend domain, only the last part is spliced by frontend, the other two parts are spliced with sqlalchemy
2026-05-20 17:31:49 +08:00
Zayden-Jung d2162f93e0 fix context; decoupling decode_token algo 2026-05-20 15:40:59 +08:00
Zayden-Jung b95cce592f fullfill graphql usertype; change db session to dep injection 2026-05-20 14:40:05 +08:00
Zayden-Jung f3b7fedade feat(graphql): implement secure session refresh mutation with token rotation 2026-05-20 03:10:07 +08:00
Zayden-Jung 9dfe000f2d refactor(auth): remove legacy RESTful user routes and migrate authentication to GraphQL context
- Rename get_current_user to get_current_user_id in auth/deps.py to clarify intent
- Remove legacy /me, /logout routes, and placeholder function from auth/__init__.py
- Remove dependency_overrides from app/main.py
- Update CustomContext to resolve and cache full User objects via db query
2026-05-20 02:52:54 +08:00
Zayden-Jung d54aeaa61a load dotenv everywhere it's needed; postgresql pool pre ping 2026-05-20 02:21:22 +08:00
Zayden-Jung c68d88b8c1 feat(auth): integrate Google OAuth2 and secure cookie management
- Configured CORS middleware to allow cross-origin requests from the frontend.
- Implemented Google OAuth2 login flow and post-auth frontend redirection.
- Managed secure HTTP-only cookies for access, refresh, and state tokens.
2026-05-19 23:55:03 +08:00
Zayden-Jung 2b5e193046 auth.py is a duplication of google.py; rename jwt.py -> tokens.py avoid confuse with pyjwt 2026-05-19 23:23:21 +08:00
Zayden-Jung a073fa5962 pyjwt not jwt 2026-05-19 22:56:04 +08:00
Zayden-Jung af5f7b38f8 auth/ is now an independent package 2026-05-19 22:31:30 +08:00
Zayden-Jung 623fac8749 app/auth/ is now an independent package, import nothing from app/ 2026-05-19 22:29:21 +08:00
Zayden-Jung fe1fde778f fix jwt token decoding; correctly import google auth routes; adjust app/auth/ package 2026-05-19 22:21:27 +08:00
Zayden-Jung 419af38684 fix missing type annotations for graphql query field arguments 2026-05-19 02:03:35 +08:00
Zayden-Jung b58f74d59b discard adjustable models' id prefix & payload length 2026-05-19 01:54:06 +08:00
Zayden-Jung 86b030930c fix bugs for launch: postgresql url for sqlalchemy; auto loading env 2026-05-19 01:39:57 +08:00
Zayden-Jung a83befe109 use strawberry dataLoader to solve N+1 query problem 2026-05-19 00:28:04 +08:00
Zayden-Jung 9c55cc675e introduce graphql 2026-05-19 00:24:45 +08:00
Zayden-Jung 78f15c80a1 enrich user-project interaction statistics 2026-05-19 00:05:56 +08:00
Zayden-Jung 0377d5071f background task for s3 avatar uploading 2026-05-18 23:51:59 +08:00
Zayden-Jung 4388a6781a add default avatar gen is google avatar not exists 2026-05-18 23:45:17 +08:00
26 changed files with 855 additions and 235 deletions
+15 -4
View File
@@ -6,16 +6,27 @@ prompts:
preset: preset:
auth: auth:
- "app/auth.py" - "auth/*"
- "app/auth/*"
s3: s3:
- "app/models/base.py" - "app/models/base.py"
- "app/models/user.py" - "app/models/user.py"
- "app/utils/s3.py" - "app/utils/s3.py"
- "app/auth.py" - "auth.py"
db_model: db_model:
- "app/models/*" - "app/models/*"
- "app/db.py" - "app/db.py"
- "app/auth.py" - "app/main.py"
graphql:
- "app/graphql/*"
- "app/main.py"
get_current_user:
- "app/main.py"
- "auth/__init__.py"
- "auth/deps.py"
- "auth/tokens.py"
- "auth/oauth/google.py"
- "app/graphql/*"
-159
View File
@@ -1,159 +0,0 @@
import httpx
from fastapi import APIRouter, Depends, Request, HTTPException
from fastapi.responses import RedirectResponse
import os
import secrets
from app.auth.deps import get_current_user
from app.db import SessionLocal
from app.models import User
from app.auth.jwt import create_jwt_token, create_refresh_token, decode_token
from app.utils.format import get_image_url
router = APIRouter()
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI")
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
@router.get("/login/google")
def login_google():
state = secrets.token_urlsafe(16)
url = (
f"{GOOGLE_AUTH_URL}"
f"?client_id={GOOGLE_CLIENT_ID}"
f"&redirect_uri={GOOGLE_REDIRECT_URI}"
f"&response_type=code"
f"&scope=openid email profile"
f"&state={state}"
)
response = RedirectResponse(url)
response.set_cookie(
"oauth_state",
state,
httponly=True,
secure=True,
samesite="lax"
)
return response
@router.get("/auth/callback")
async def auth_callback(request: Request, code: str, state: str):
cookie_state = request.cookies.get("oauth_state")
if not cookie_state or cookie_state != state:
raise HTTPException(status_code=400, detail="Invalid state")
async with httpx.AsyncClient() as client:
token_res = await client.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uri": GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
},
)
token_json = token_res.json()
access_token = token_json["access_token"]
user_res = await client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
user_info = user_res.json()
db = SessionLocal()
try:
user = db.query(User).filter(User.email == user_info["email"]).first()
if not user:
# 提取邮箱前缀作为 handle 基础
# 比如 "john.doe@gmail.com" -> "john_doe"
base_handle = user_info["email"].split("@")[0].replace(".", "_").lower()
# 拼接一个随机短字符串,确保 handle 的唯一性
import secrets
random_suffix = secrets.token_hex(3) # 生成 6 位随机字符
suggested_handle = f"{base_handle}_{random_suffix}"
user = User(
google_id=user_info["id"],
email=user_info["email"],
name=user_info["name"],
avatar=user_info["picture"],
handle=suggested_handle,
)
db.add(user)
db.commit()
db.refresh(user)
# 异步/同步将头像转存到 S3
from app.utils.s3 import upload_google_avatar_to_s3
try:
s3_avatar_url = await upload_google_avatar_to_s3(user_info["picture"], str(user.id))
# 将更新后的 S3 URL 写回数据库
user.avatar = s3_avatar_url
db.commit()
except Exception as s3_err:
print(f"S3 Upload failed: {s3_err}")
# 这里不需要再次 refresh,除非后面还要用到更新后的 user 对象
# db.refresh(user)
finally:
db.close()
jwt_token = create_jwt_token({
"sub": user.id,
"email": user.email,
})
refresh_token = create_refresh_token({
"sub": user.id,
"email": user.email,
})
return {
"access_token": jwt_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"user": {
"email": user.email,
"name": user.name,
"avatar": get_image_url(user.avatar),
}
}
@router.get("/me")
def me(user = Depends(get_current_user)):
return {
"email": user.email,
"name": user.name,
"avatar": get_image_url(user.avatar),
}
@router.post("/refresh")
def refresh_token(refresh_token: str):
payload = decode_token(refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=401)
user_id = payload.get("sub")
new_access = create_jwt_token({"sub": user_id})
return {"access_token": new_access}
@router.post("/logout")
def logout():
return {"message": "ok"}
+32 -1
View File
@@ -1,8 +1,39 @@
import os import os
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from dotenv import load_dotenv
# 加载 .env 文件
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL") DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_engine(DATABASE_URL) # 自动将旧版的 postgres:// 替换为 SQLAlchemy 强要求的 postgresql://
if DATABASE_URL and DATABASE_URL.startswith("postgres://"):
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1)
elif DATABASE_URL and DATABASE_URL.startswith("postgres+"):
DATABASE_URL = DATABASE_URL.replace("postgres+", "postgresql+", 1)
# 如果环境变量彻底缺失,做一个安全的报错提示
if not DATABASE_URL:
raise ValueError("DATABASE_URL environment variable is not set or empty.")
engine = create_engine(
DATABASE_URL,
pool_recycle=3600,
pool_pre_ping=True,
connect_args={
"keepalives": 1,
"keepalives_idle": 30,
"keepalives_interval": 10,
"keepalives_count": 5
}
)
SessionLocal = sessionmaker(bind=engine) SessionLocal = sessionmaker(bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+74
View File
@@ -0,0 +1,74 @@
from app.models.user import User
from auth.deps import get_current_user_id
from fastapi import Request
from strawberry.fastapi import BaseContext
from strawberry.dataloader import DataLoader
from typing import List, Optional
from collections import defaultdict
from app.db import SessionLocal
from app.models import WorkspaceMember
from typing import AsyncGenerator
# 定义批量查询函数:接收一组 workspace ID 列表,返回相同长度的结果列表
async def load_members_by_workspace_ids(workspace_ids: List[str]) -> List[List[WorkspaceMember]]:
db = SessionLocal()
try:
# 用一条 IN 语句查出所有相关成员
members = db.query(WorkspaceMember).filter(WorkspaceMember.workspace_id.in_(workspace_ids)).all()
# 将结果归类到对应的 workspace ID
member_map = defaultdict(list)
for member in members:
member_map[member.workspace_id].append(member)
# 严格按照传入的 workspace ID 顺序返回结果列表
return [member_map[w_id] for w_id in workspace_ids]
finally:
db.close()
class CustomContext(BaseContext):
def __init__(self, request: Request):
super().__init__()
self.db = SessionLocal()
self.request = request
self._current_user: Optional[User] = None
self.members_loader = DataLoader(load_fn=load_members_by_workspace_ids)
async def get_current_user(self) -> User | None:
"""在 Context 级别负责把 user_id 变成真正的数据库 User 对象"""
if self._current_user is not None:
return self._current_user
try:
# 打印 Request Headers,确认 Token 是否真的传过来了
# print(f"DEBUG: Auth Headers: {self.request.headers.get('authorization')}")
# print(f"DEBUG: Cookies: {self.request.cookies}")
# 借用 auth 包的纯工具解出 ID
user_id = get_current_user_id(self.request)
# print(f"DEBUG: Extracted User ID: {user_id}") # 确认 ID 是否解出
if not user_id:
return None
# 在 Context 的数据库连接中查询完整用户
user = self.db.query(User).filter(User.id == user_id).first()
# print(f"DEBUG: DB User Found: {user}") # 确认数据库是否查到
self._current_user = user
return user
except Exception as e:
# 打印真实的异常信息
# print(f"DEBUG: Error in get_current_user: {str(e)}")
import traceback
traceback.print_exc() # 打印堆栈
return None
async def get_context(request: Request) -> AsyncGenerator[CustomContext, None]:
# print("--- NEW GRAPHQL REQUEST RECEIVED ---")
context = CustomContext(request)
try:
yield context
finally:
context.db.close() # 确保请求结束时关闭数据库连接
+103
View File
@@ -0,0 +1,103 @@
import os
import strawberry
from strawberry.types import Info
from dotenv import load_dotenv
from urllib.parse import quote
import json
from app.graphql.context import CustomContext
from auth.tokens import decode_token, create_jwt_token, create_refresh_token
from app.utils.format import get_image_url
load_dotenv()
ENV_MODE = os.getenv("ENV_MODE")
IS_PROD = ENV_MODE == "prod"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
ACCESS_TOKEN_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60
@strawberry.type
class Mutation:
@strawberry.mutation
def logout(self, info: Info[CustomContext, None]) -> str:
"""退出登录:清除浏览器的 HttpOnly Cookies"""
response = info.context.response
# 让浏览器立刻过期这些 cookie
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")
response.delete_cookie("user_info")
return "ok"
@strawberry.mutation
async def refresh_session(self, info: Info[CustomContext, None]) -> str:
"""
刷新 Session:如果 access_token 过期了,前端可以调用这个接口用 refresh_token 换新的 access_token。
同时如果 refresh_token 已经过半寿命了,这个接口也会顺便轮转一个新的 refresh_token,确保用户体验的连续性。
"""
request = info.context.request
response = info.context.response
# 从 Cookie 获取并校验 refresh_token
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
raise Exception("No refresh token found in cookies")
try:
payload = decode_token(refresh_token)
except Exception:
raise Exception("Invalid or expired refresh token")
if payload.get("type") != "refresh":
raise Exception("Token type mismatch")
user = await info.context.get_current_user()
# 如果 access_token 已经彻底过期导致 Context 没捞到用户,
# 我们用 refresh_token 里的 sub 去 Context 的 db 里再查一遍用户
from app.models.user import User
if not user:
user_id = payload.get("sub")
user = info.context.db.query(User).filter(User.id == user_id).first()
if not user:
raise Exception("User no longer exists")
# 始终生成并写入新的短效 access_token
token_data = {"sub": str(user.id), "email": user.email}
new_access = create_jwt_token(token_data)
ACCESS_TOKEN_MAX_AGE = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")) * 60
response.set_cookie(
key="access_token", value=new_access, httponly=True,
max_age=ACCESS_TOKEN_MAX_AGE, samesite="lax", secure=IS_PROD,
)
# 检查 refresh_token 是否已经消耗了超过一半的生命周期
import time
exp = payload.get("exp") # 过期绝对秒数
iat = payload.get("iat") # 签发绝对秒数
now = int(time.time())
# 如果当前时间已经过了总寿命的一半,就触发轮转,下发新的 refresh_token
if (now - iat) > (exp - iat) / 2:
new_refresh = create_refresh_token(token_data)
REFRESH_TOKEN_MAX_AGE = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7")) * 24 * 60 * 60
response.set_cookie(
key="refresh_token", value=new_refresh, httponly=True,
max_age=REFRESH_TOKEN_MAX_AGE, samesite="lax", secure=IS_PROD,
)
# 同步写入并续期前端所需的 user_info
response.set_cookie(
key="user_info",
value=quote(json.dumps({
"email": user.email,
"name": user.name,
"avatar": get_image_url(user.avatar) if user.avatar else None
})),
max_age=ACCESS_TOKEN_MAX_AGE,
httponly=False
)
return "ok"
+21
View File
@@ -0,0 +1,21 @@
import strawberry
from typing import Optional
from strawberry.types import Info
from app.graphql.types import UserType
from app.models import User
from app.graphql.context import CustomContext
@strawberry.type
class Query:
@strawberry.field
async def me(self, info: Info[CustomContext, None]) -> Optional[UserType]:
# 从 context 获取当前登录用户
user = await info.context.get_current_user()
# Strawberry 会自动将 SQLAlchemy 实例映射为 UserType
return user
@strawberry.field
def get_user_by_handle(self, info: Info[CustomContext, None], handle: str) -> Optional[UserType]:
db = info.context.db
return db.query(User).filter(User.handle == handle).first()
+67
View File
@@ -0,0 +1,67 @@
import strawberry
from typing import List, Optional
from datetime import datetime
from strawberry.types import Info
from app.utils.format import get_image_url
from app.models.user import User
@strawberry.type
class UserType:
id: str
email: str
name: str
handle: Optional[str]
@strawberry.field
def avatar(self, root: User) -> Optional[str]:
return root.avatar_url
# 互动统计字段
following_count: int
follower_count: int
# 统计汇总字段
owner_project_count: int
owner_likes_count: int
owner_favorites_count: int
owner_shares_count: int
admin_project_count: int
admin_likes_count: int
admin_favorites_count: int
admin_shares_count: int
user_project_count: int
user_likes_count: int
user_favorites_count: int
user_shares_count: int
created_at: datetime
# 动态解析字段:例如自动处理头像的绝对路径
@strawberry.field
def avatar_url(self) -> Optional[str]:
return get_image_url(self.avatar) if self.avatar else None
@strawberry.type
class WorkspaceMemberType:
id: strawberry.ID
workspace_id: str
user_id: Optional[str]
role: str
status: str
@strawberry.type
class WorkspaceType:
id: strawberry.ID
name: str
slug: str
logo: Optional[str]
description: Optional[str]
storage_used: int
# 支持前端按需加载关联的成员
@strawberry.field
def members(self, info: Info) -> List[WorkspaceMemberType]:
return info.context.members_loader.load(self.id)
+49 -5
View File
@@ -1,10 +1,54 @@
from fastapi import FastAPI import os
from app.auth import router as auth_router from fastapi import FastAPI, Depends, HTTPException
from app.models import Base from fastapi.middleware.cors import CORSMiddleware
from app.db import engine from strawberry import Schema
from strawberry.fastapi import GraphQLRouter
from dotenv import load_dotenv
from auth import router as auth_router
from app.media.router import router as media_router
from app.db import engine, SessionLocal
from app.models import Base, User
from app.utils.format import get_image_url
# 导入 GraphQL 组件
from app.graphql.queries import Query
from app.graphql.mutations import Mutation
from app.graphql.context import get_context
# 自动建表
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# 创建 FastAPI 实例
app = FastAPI() app = FastAPI()
app.include_router(auth_router) # 获取环境变量字符串,并转成列表
load_dotenv()
origins_str = os.getenv("CORS_ORIGINS", "http://localhost:3000")
origins = [origin.strip() for origin in origins_str.split(",")]
# 配置 CORS 中间件,允许前端访问
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 组装 GraphQL Schema
schema = Schema(query=Query, mutation=Mutation)
# 创建 GraphQL 路由并注入上下文
graphql_router = GraphQLRouter(schema, context_getter=get_context)
# 挂载路由
app.include_router(auth_router) # 保留现有的 auth 路由供 Google OAuth 回调使用
app.include_router(media_router) # 挂载媒体路由,提供头像代理访问等等
app.include_router(graphql_router, prefix="/graphql") # 挂载 GraphQL 终点,前端以后只需要请求 /graphql
# 打印路由测试
for route in app.routes:
methods = getattr(route, "methods", "N/A")
print(f"Path: {route.path} | Name: {route.name} | Methods: {methods}")
+45
View File
@@ -0,0 +1,45 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
import oss2
from app.utils.s3 import bucket
router = APIRouter(prefix="/v1/media", tags=["Media"])
@router.get("/{file_path:path}")
async def get_avatar(file_path: str):
"""
通过流式传输代理阿里云 OSS 中的图片
file_path 接收 avatars/xxx/avatar.jpg 等任何相对路径
"""
s3_key = file_path.lstrip("/")
if not s3_key:
raise HTTPException(status_code=400, detail="Invalid file path")
try:
# 从阿里云 OSS 获取对象
# oss2 的 get_object 是同步阻塞的
# 初期可以直接用,后续高并发可以放入 run_in_executor
oss_object = bucket.get_object(s3_key)
# 获取文件的 Content-Type
content_type = oss_object.headers.get("Content-Type", "image/jpeg")
# 创建一个生成器来分块读取 OSS 数据,防止大文件时撑爆内存
def image_stream():
while True:
chunk = oss_object.read(1024 * 64) # 每次读 64KB
if not chunk:
break
yield chunk
# 流式返回给前端
return StreamingResponse(image_stream(), media_type=content_type)
except oss2.exceptions.NoSuchKey:
# 阿里云 OSS 专属的 404 异常
raise HTTPException(status_code=404, detail="Image not found in storage")
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail="Internal storage service error")
+8 -6
View File
@@ -56,22 +56,24 @@ class AutoID:
不再需要手动定义任何 prefix,全自动根据 __tablename__ 编码生成 不再需要手动定义任何 prefix,全自动根据 __tablename__ 编码生成
支持子类自定义长度 支持子类自定义长度
""" """
# 默认配置 # 全局固定配置
_id_prefix_len = 3 # 前缀长度 ID_PREFIX_LEN = 3
_id_payload_len = 22 # 主体长度 ID_PAYLOAD_LEN = 22
@classmethod @classmethod
def id_length(cls) -> int: def id_length(cls) -> int:
"""返回当前类 ID 的总长度""" """返回当前类 ID 的总长度"""
return cls._id_prefix_len + cls._id_payload_len return cls.ID_PREFIX_LEN + cls.ID_PAYLOAD_LEN
@declared_attr @declared_attr
def id(cls): def id(cls):
# 运行时动态获取子类的 __tablename__ # 运行时动态获取子类的 __tablename__
tablename = cls.__tablename__ tablename = cls.__tablename__
# 获取子类配置的长度 # 获取子类配置的长度
p_len = cls._id_prefix_len p_len = AutoID.ID_PREFIX_LEN
s_len = cls._id_payload_len s_len = AutoID.ID_PAYLOAD_LEN
prefix = encode_tablename_to_prefix(tablename, length=p_len) prefix = encode_tablename_to_prefix(tablename, length=p_len)
# 核心工厂函数:拼接【编码后的表前缀】和【递增Payload】 # 核心工厂函数:拼接【编码后的表前缀】和【递增Payload】
+2 -9
View File
@@ -1,25 +1,18 @@
from typing import TYPE_CHECKING
from sqlalchemy import Column, DateTime, String, ForeignKey, UniqueConstraint from sqlalchemy import Column, DateTime, String, ForeignKey, UniqueConstraint
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .base import AutoID, Base from .base import AutoID, Base
# 只有在静态检查时才导入
# 运行阶段这段代码会被跳过,彻底避免循环导入
# 不确定是否用得上,但感觉这是个好习惯,尤其在模型之间有外键关系时
if TYPE_CHECKING:
from .user import User
class Follow(Base, AutoID): class Follow(Base, AutoID):
"""用户关注关系表""" """用户关注关系表"""
__tablename__ = "follows" __tablename__ = "follows"
follower_id = Column( follower_id = Column(
String(User.id_length()), String(AutoID.id_length()),
ForeignKey("users.id", ondelete="CASCADE"), ForeignKey("users.id", ondelete="CASCADE"),
index=True index=True
) )
followee_id = Column( followee_id = Column(
String(User.id_length()), String(AutoID.id_length()),
ForeignKey("users.id", ondelete="CASCADE"), ForeignKey("users.id", ondelete="CASCADE"),
index=True index=True
) )
+11 -17
View File
@@ -1,24 +1,18 @@
from typing import TYPE_CHECKING
from sqlalchemy import Column, DateTime, String, ForeignKey, UniqueConstraint from sqlalchemy import Column, DateTime, String, ForeignKey, UniqueConstraint
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .base import AutoID, Base from .base import AutoID, Base
if TYPE_CHECKING:
from .user import User
from .project import ProjectItem
from .workspace import Workspace
class ProjectLike(Base, AutoID): class ProjectLike(Base, AutoID):
"""项目点赞表""" """项目点赞表"""
__tablename__ = "project_likes" __tablename__ = "project_likes"
user_id = Column( user_id = Column(
String(User.id_length()), String(AutoID.id_length()),
ForeignKey("users.id", ondelete="CASCADE"), ForeignKey("users.id", ondelete="CASCADE"),
index=True index=True
) )
project_item_id = Column( project_item_id = Column(
String(ProjectItem.id_length()), String(AutoID.id_length()),
ForeignKey("project_items.id", ondelete="CASCADE"), ForeignKey("project_items.id", ondelete="CASCADE"),
index=True index=True
) )
@@ -34,12 +28,12 @@ class ProjectFavorite(Base, AutoID):
__tablename__ = "project_favorites" __tablename__ = "project_favorites"
user_id = Column( user_id = Column(
String(User.id_length()), String(AutoID.id_length()),
ForeignKey("users.id", ondelete="CASCADE"), ForeignKey("users.id", ondelete="CASCADE"),
index=True index=True
) )
project_item_id = Column( project_item_id = Column(
String(ProjectItem.id_length()), String(AutoID.id_length()),
ForeignKey("project_items.id", ondelete="CASCADE"), ForeignKey("project_items.id", ondelete="CASCADE"),
index=True index=True
) )
@@ -55,12 +49,12 @@ class ProjectShare(Base, AutoID):
__tablename__ = "project_shares" __tablename__ = "project_shares"
user_id = Column( user_id = Column(
String(User.id_length()), String(AutoID.id_length()),
ForeignKey("users.id", ondelete="CASCADE"), ForeignKey("users.id", ondelete="CASCADE"),
index=True index=True
) )
project_item_id = Column( project_item_id = Column(
String(ProjectItem.id_length()), String(AutoID.id_length()),
ForeignKey("project_items.id", ondelete="CASCADE"), ForeignKey("project_items.id", ondelete="CASCADE"),
index=True index=True
) )
@@ -69,10 +63,10 @@ class ProjectShare(Base, AutoID):
share_target = Column(String, default="internal", nullable=False) share_target = Column(String, default="internal", nullable=False)
# 如果是转发到特定工作区,可以记录目标工作区 ID # 如果是转发到特定工作区,可以记录目标工作区 ID
target_workspace_id = Column( # target_workspace_id = Column(
String(Workspace.id_length()), # String(Workspace.id_length()),
ForeignKey("workspaces.id", ondelete="SET NULL"), # ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True # nullable=True
) # )
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
+2 -7
View File
@@ -1,18 +1,13 @@
from typing import TYPE_CHECKING
from sqlalchemy import Column, DateTime, String, ForeignKey, BigInteger from sqlalchemy import Column, DateTime, String, ForeignKey, BigInteger
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .base import AutoID, Base from .base import AutoID, Base
if TYPE_CHECKING:
from .user import User
from .workspace import Workspace
class LibraryItem(Base, AutoID): class LibraryItem(Base, AutoID):
"""工作区共享媒体/文件资源库""" """工作区共享媒体/文件资源库"""
__tablename__ = "library_items" __tablename__ = "library_items"
workspace_id = Column( workspace_id = Column(
String(Workspace.id_length()), String(AutoID.id_length()),
ForeignKey("workspaces.id", ondelete="CASCADE"), ForeignKey("workspaces.id", ondelete="CASCADE"),
index=True, index=True,
) )
@@ -34,7 +29,7 @@ class LibraryItem(Base, AutoID):
mime_type = Column(String, nullable=True) # 媒体类型,例如 'image/jpeg', 'video/mp4', 'application/pdf' mime_type = Column(String, nullable=True) # 媒体类型,例如 'image/jpeg', 'video/mp4', 'application/pdf'
uploaded_by = Column( uploaded_by = Column(
String(User.id_length()), String(AutoID.id_length()),
ForeignKey("users.id", ondelete="SET NULL"), ForeignKey("users.id", ondelete="SET NULL"),
nullable=True, nullable=True,
) )
+8 -7
View File
@@ -1,21 +1,18 @@
from typing import TYPE_CHECKING
from sqlalchemy import Column, DateTime, String, ForeignKey, Integer from sqlalchemy import Column, DateTime, String, ForeignKey, Integer
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .base import AutoID, Base from .base import AutoID, Base
if TYPE_CHECKING:
from .workspace import Workspace
class ProjectItem(Base, AutoID): class ProjectItem(Base, AutoID):
"""项目与文件夹的树状虚拟文件系统""" """项目与文件夹的树状虚拟文件系统"""
__tablename__ = "project_items" __tablename__ = "project_items"
workspace_id = Column( workspace_id = Column(
String(Workspace.id_length()), String(AutoID.id_length()),
ForeignKey("workspaces.id", ondelete="CASCADE"), ForeignKey("workspaces.id", ondelete="CASCADE"),
index=True, index=True,
) )
# 自引用外键:如果是根目录下的顶级节点,则 parent_id 为 Null # 自引用外键
# 如果是根目录下的顶级节点,则 parent_id 为 Null
parent_id = Column( parent_id = Column(
String(AutoID.id_length()), # 其实调用的就是自身的 id_length 方法 String(AutoID.id_length()), # 其实调用的就是自身的 id_length 方法
ForeignKey("project_items.id", ondelete="CASCADE"), ForeignKey("project_items.id", ondelete="CASCADE"),
@@ -30,10 +27,14 @@ class ProjectItem(Base, AutoID):
# 如果是 project 节点,可以用这一列来存储其内容或配置快照(如 JSON 文本) # 如果是 project 节点,可以用这一列来存储其内容或配置快照(如 JSON 文本)
content = Column(String, nullable=True) content = Column(String, nullable=True)
# --- 新增:富媒体与播放信息 --- # --- 富媒体与播放信息 ---
cover_url = Column(String, nullable=True) # 封面图(可以是图片、GIF 或短视频预览 URL) cover_url = Column(String, nullable=True) # 封面图(可以是图片、GIF 或短视频预览 URL)
duration = Column(Integer, default=0) # 媒体时长(单位:秒,非视频/GIF 则为 0) duration = Column(Integer, default=0) # 媒体时长(单位:秒,非视频/GIF 则为 0)
view_count = Column(Integer, default=0, nullable=False) view_count = Column(Integer, default=0, nullable=False)
like_count = Column(Integer, default=0, nullable=False, server_default="0")
favorite_count = Column(Integer, default=0, nullable=False, server_default="0")
share_count = Column(Integer, default=0, nullable=False, server_default="0")
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+31 -2
View File
@@ -17,11 +17,40 @@ class User(Base, AutoID):
handle = Column(String, unique=True, index=True, nullable=True) handle = Column(String, unique=True, index=True, nullable=True)
avatar = Column(String) avatar = Column(String)
@property
def avatar_url(self) -> str | None:
if not self.avatar:
return None
# 如果是第三方(如未迁移成功时降级的 Google 原始链接),直接返回
if self.avatar.startswith(("http://", "https://")):
return self.avatar
path = self.avatar.lstrip("/")
return f"/v1/media/{path}"
# ---- 互动统计字段 ---- # ---- 互动统计字段 ----
following_count = Column(Integer, default=0, nullable=False, server_default="0") # 关注了多少人 following_count = Column(Integer, default=0, nullable=False, server_default="0") # 关注了多少人
follower_count = Column(Integer, default=0, nullable=False, server_default="0") # 粉丝数 follower_count = Column(Integer, default=0, nullable=False, server_default="0") # 粉丝数
likes_received_count = Column(Integer, default=0, nullable=False, server_default="0") # 收到的点赞数
favorites_received_count = Column(Integer, default=0, nullable=False, server_default="0") # 收到的收藏 # 作为 Owner (所有者) 身份在各个空间获得的累计总
owner_project_count = Column(Integer, default=0, nullable=False, server_default="0")
owner_likes_count = Column(Integer, default=0, nullable=False, server_default="0")
owner_favorites_count = Column(Integer, default=0, nullable=False, server_default="0")
owner_shares_count = Column(Integer, default=0, nullable=False, server_default="0")
# 作为 Admin (管理员) 身份在各个空间获得的累计总数
admin_project_count = Column(Integer, default=0, nullable=False, server_default="0")
admin_likes_count = Column(Integer, default=0, nullable=False, server_default="0")
admin_favorites_count = Column(Integer, default=0, nullable=False, server_default="0")
admin_shares_count = Column(Integer, default=0, nullable=False, server_default="0")
# 作为 User (普通用户) 身份在各个空间获得的累计总数
user_project_count = Column(Integer, default=0, nullable=False, server_default="0")
user_likes_count = Column(Integer, default=0, nullable=False, server_default="0")
user_favorites_count = Column(Integer, default=0, nullable=False, server_default="0")
user_shares_count = Column(Integer, default=0, nullable=False, server_default="0")
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
+13 -7
View File
@@ -1,11 +1,7 @@
from typing import TYPE_CHECKING from sqlalchemy import Column, DateTime, String, ForeignKey, BigInteger, UniqueConstraint, Integer
from sqlalchemy import Column, DateTime, String, ForeignKey, BigInteger, UniqueConstraint
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .base import AutoID, Base from .base import AutoID, Base
if TYPE_CHECKING:
from .user import User
class Workspace(Base, AutoID): class Workspace(Base, AutoID):
"""工作区表""" """工作区表"""
__tablename__ = "workspaces" __tablename__ = "workspaces"
@@ -16,6 +12,11 @@ class Workspace(Base, AutoID):
logo = Column(String, nullable=True) logo = Column(String, nullable=True)
description = Column(String, nullable=True) description = Column(String, nullable=True)
total_project_count = Column(Integer, default=0, nullable=False, server_default="0")
total_like_count = Column(Integer, default=0, nullable=False, server_default="0")
total_favorite_count = Column(Integer, default=0, nullable=False, server_default="0")
total_share_count = Column(Integer, default=0, nullable=False, server_default="0")
# 存储容量上限(单位:字节 Bytes),默认 5GB # 存储容量上限(单位:字节 Bytes),默认 5GB
storage_limit = Column(BigInteger, default=5 * 1024 * 1024 * 1024) storage_limit = Column(BigInteger, default=5 * 1024 * 1024 * 1024)
storage_used = Column(BigInteger, default=0, nullable=False) storage_used = Column(BigInteger, default=0, nullable=False)
@@ -29,13 +30,13 @@ class WorkspaceMember(Base, AutoID):
__tablename__ = "workspace_members" __tablename__ = "workspace_members"
workspace_id = Column( workspace_id = Column(
String(Workspace.id_length()), String(AutoID.id_length()),
ForeignKey("workspaces.id", ondelete="CASCADE"), ForeignKey("workspaces.id", ondelete="CASCADE"),
index=True, index=True,
) )
# 允许 user_id 为空,因为被邀请的人可能还没注册系统,我们先记录他的邮箱 # 允许 user_id 为空,因为被邀请的人可能还没注册系统,我们先记录他的邮箱
user_id = Column( user_id = Column(
String(User.id_length()), String(AutoID.id_length()),
ForeignKey("users.id", ondelete="CASCADE"), ForeignKey("users.id", ondelete="CASCADE"),
nullable=True, nullable=True,
index=True, index=True,
@@ -47,6 +48,11 @@ class WorkspaceMember(Base, AutoID):
# 角色权限:'owner' (所有者), 'admin' (管理员), 'user' (普通用户) # 角色权限:'owner' (所有者), 'admin' (管理员), 'user' (普通用户)
role = Column(String, default="user", nullable=False) role = Column(String, default="user", nullable=False)
likes_received_count = Column(Integer, default=0, nullable=False, server_default="0")
favorites_received_count = Column(Integer, default=0, nullable=False, server_default="0")
shares_received_count = Column(Integer, default=0, nullable=False, server_default="0")
# invite_token 用于生成邀请链接,token_expires_at 用于设置链接过期时间
invite_token = Column(String, nullable=True, unique=True) invite_token = Column(String, nullable=True, unique=True)
token_expires_at = Column(DateTime(timezone=True), nullable=True) token_expires_at = Column(DateTime(timezone=True), nullable=True)
+3
View File
@@ -1,4 +1,7 @@
import os import os
from dotenv import load_dotenv
load_dotenv()
OSS_BASE_URL = os.getenv("OSS_BASE_URL", "").rstrip("/") OSS_BASE_URL = os.getenv("OSS_BASE_URL", "").rstrip("/")
+3 -2
View File
@@ -1,8 +1,9 @@
import oss2 import oss2
import httpx import httpx
import os import os
import io from dotenv import load_dotenv
from fastapi import HTTPException
load_dotenv()
# 加载配置 # 加载配置
ACCESS_KEY_ID = os.getenv("S3_ACCESS_KEY_ID") ACCESS_KEY_ID = os.getenv("S3_ACCESS_KEY_ID")
+19
View File
@@ -0,0 +1,19 @@
import os
from fastapi import APIRouter
from .deps import get_current_user_id
from .tokens import create_jwt_token, create_refresh_token, verify_access_token
from .oauth import oauth_router
# 创建全局 auth 路由器并挂载第三方 OAuth 的路由器
router = APIRouter(tags=["Authentication"])
router.include_router(oauth_router)
# 显式声明暴露的接口
__all__ = [
"router",
"get_current_user_id",
"create_jwt_token",
"create_refresh_token",
"verify_access_token",
]
+9 -5
View File
@@ -1,19 +1,23 @@
from fastapi import Request, HTTPException from fastapi import Request, HTTPException
import app.auth.jwt as jwt
import os import os
from dotenv import load_dotenv
from .tokens import decode_token
load_dotenv()
JWT_SECRET = os.getenv("JWT_SECRET") JWT_SECRET = os.getenv("JWT_SECRET")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
def get_current_user_id(request: Request):
def get_current_user(request: Request): """仅从 Cookie 中提取并解密出用户的 user_id (sub)"""
token = request.cookies.get("access_token") token = request.cookies.get("access_token")
if not token: if not token:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
try: try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) payload = decode_token(token)
return payload["user_id"] return payload["sub"]
except: except:
raise HTTPException(status_code=401, detail="Invalid token") raise HTTPException(status_code=401, detail="Invalid token")
+12
View File
@@ -0,0 +1,12 @@
from fastapi import APIRouter
# 导入各个渠道的路由
from .google import router as google_router
# from .github import router as github_router
# 创建一个总的 oauth 路由器
oauth_router = APIRouter()
# 包含 Google 的路由
oauth_router.include_router(google_router)
# oauth_router.include_router(github_router)
+217
View File
@@ -0,0 +1,217 @@
from ast import Is
import httpx
from fastapi import APIRouter, Depends, Request, HTTPException, BackgroundTasks
from fastapi.responses import RedirectResponse
import os
import secrets
import json
from urllib.parse import quote
from dotenv import load_dotenv
from sqlalchemy.orm import Session
from app.db import SessionLocal, get_db
from app.models import User
from auth.tokens import create_jwt_token, create_refresh_token, decode_token
from app.utils.format import get_image_url
router = APIRouter()
load_dotenv()
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI")
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
ENV_MODE = os.getenv("ENV_MODE")
IS_PROD = ENV_MODE == "prod"
# 在文件顶部获取环境变量
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
# 统一转换为秒数(max_age 需要秒)
ACCESS_TOKEN_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60
REFRESH_TOKEN_MAX_AGE = REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
# 定义独立的后台任务函数,用于处理头像转存到 S3 的耗时操作
async def async_upload_avatar_task(google_avatar_url: str, user_id: str):
"""
后台任务函数:下载 Google 头像并上传到 S3,然后更新用户记录。
因为主接口的 db 已经关闭,这里需要独立管理 db 的生命周期。
"""
from app.utils.s3 import upload_google_avatar_to_s3
# 如果 Google 本身就没给头像,无需转存
if not google_avatar_url:
return
try:
# 执行耗时的网络 I/O:下载并上传到 S3
s3_avatar_url = await upload_google_avatar_to_s3(google_avatar_url, user_id)
# 成功后,开启一个独立的数据库连接写回数据
db_task = SessionLocal()
try:
user = db_task.query(User).filter(User.id == user_id).first()
if user:
user.avatar = s3_avatar_url
db_task.commit()
print(f"成功在后台为用户 {user_id} 转存头像到 S3")
finally:
db_task.close() # 必须手动关闭连接
except Exception as s3_err:
# 如果 S3 依然失败,打印错误日志,但此时绝不会打扰到已经登录的用户
print(f"Background S3 Upload failed for user {user_id}: {s3_err}")
@router.get("/login/google")
def login_google():
state = secrets.token_urlsafe(16)
url = (
f"{GOOGLE_AUTH_URL}"
f"?client_id={GOOGLE_CLIENT_ID}"
f"&redirect_uri={GOOGLE_REDIRECT_URI}"
f"&response_type=code"
f"&scope=openid email profile"
f"&state={state}"
)
response = RedirectResponse(url)
response.set_cookie(
"oauth_state",
state,
httponly=True,
secure=True,
samesite="lax"
)
return response
@router.get("/auth/callback")
async def auth_callback(
request: Request,
code: str,
state: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db)
):
cookie_state = request.cookies.get("oauth_state")
if not cookie_state or cookie_state != state:
raise HTTPException(status_code=400, detail="Invalid state")
async with httpx.AsyncClient() as client:
token_res = await client.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uri": GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
},
)
token_json = token_res.json()
access_token = token_json["access_token"]
user_res = await client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
user_info = user_res.json()
user = db.query(User).filter(User.email == user_info["email"]).first()
if not user:
# 提取邮箱前缀作为 handle 基础
# 比如 "john.doe@gmail.com" -> "john_doe"
base_handle = user_info["email"].split("@")[0].replace(".", "_").lower()
# 拼接一个随机短字符串,确保 handle 的唯一性
import secrets
random_suffix = secrets.token_hex(3) # 生成 6 位随机字符
suggested_handle = f"{base_handle}_{random_suffix}"
# 如果 Google 没有头像,使用 DiceBear 头像生成器作为兜底
google_avatar = user_info.get("picture")
if not google_avatar:
default_avatar_url = f"https://api.dicebear.com/7.x/lorelei/svg?seed={base_handle}"
else:
default_avatar_url = google_avatar
user = User(
google_id=user_info["id"],
email=user_info["email"],
name=user_info["name"],
avatar=default_avatar_url,
handle=suggested_handle,
)
db.add(user)
db.commit()
db.refresh(user)
# 异步/同步将头像转存到 S3
# FastAPI 会在把下方的 return 响应发送给浏览器之后,立刻在后台启动这个任务
if google_avatar:
background_tasks.add_task(
async_upload_avatar_task,
google_avatar,
str(user.id)
)
# 这里不需要再次 refresh,除非后面还要用到更新后的 user 对象
# db.refresh(user)
jwt_token = create_jwt_token({
"sub": user.id,
"email": user.email,
})
refresh_token = create_refresh_token({
"sub": user.id,
"email": user.email,
})
response = RedirectResponse(url=f"{FRONTEND_URL}")
response.set_cookie(
key="access_token",
value=jwt_token,
httponly=True,
max_age=ACCESS_TOKEN_MAX_AGE,
samesite="lax",
secure=IS_PROD,
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
max_age=REFRESH_TOKEN_MAX_AGE,
samesite="lax",
secure=IS_PROD,
)
response.set_cookie(
key="user_info",
value=quote(json.dumps({
"email": user.email,
"name": user.name,
"avatar": user.avatar
})),
max_age=ACCESS_TOKEN_MAX_AGE,
httponly=False
)
response.delete_cookie("oauth_state")
return response
+4 -1
View File
@@ -1,7 +1,10 @@
import app.auth.jwt as jwt
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import os import os
import jwt
from fastapi import HTTPException, status from fastapi import HTTPException, status
from dotenv import load_dotenv
load_dotenv()
JWT_SECRET = os.getenv("JWT_SECRET") JWT_SECRET = os.getenv("JWT_SECRET")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+3
View File
@@ -7,12 +7,15 @@ requires-python = ">=3.14"
dependencies = [ dependencies = [
"alembic>=1.18.4", "alembic>=1.18.4",
"boto3>=1.43.9", "boto3>=1.43.9",
"dotenv>=0.9.9",
"fastapi>=0.136.1", "fastapi>=0.136.1",
"httpx>=0.28.1", "httpx>=0.28.1",
"oss2>=2.19.1", "oss2>=2.19.1",
"psycopg2-binary>=2.9.12", "psycopg2-binary>=2.9.12",
"pyjwt[crypto]>=2.12.1",
"python-jose>=3.5.0", "python-jose>=3.5.0",
"sqlalchemy>=2.0.49", "sqlalchemy>=2.0.49",
"strawberry-graphql[fastapi]>=0.315.5",
"ulid-py>=1.1.0", "ulid-py>=1.1.0",
"uvicorn>=0.47.0", "uvicorn>=0.47.0",
] ]
Generated
+101
View File
@@ -145,12 +145,15 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "boto3" }, { name = "boto3" },
{ name = "dotenv" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "oss2" }, { name = "oss2" },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-jose" }, { name = "python-jose" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "strawberry-graphql", extra = ["fastapi"] },
{ name = "ulid-py" }, { name = "ulid-py" },
{ name = "uvicorn" }, { name = "uvicorn" },
] ]
@@ -159,12 +162,15 @@ dependencies = [
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.18.4" }, { name = "alembic", specifier = ">=1.18.4" },
{ name = "boto3", specifier = ">=1.43.9" }, { name = "boto3", specifier = ">=1.43.9" },
{ name = "dotenv", specifier = ">=0.9.9" },
{ name = "fastapi", specifier = ">=0.136.1" }, { name = "fastapi", specifier = ">=0.136.1" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "oss2", specifier = ">=2.19.1" }, { name = "oss2", specifier = ">=2.19.1" },
{ name = "psycopg2-binary", specifier = ">=2.9.12" }, { name = "psycopg2-binary", specifier = ">=2.9.12" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" },
{ name = "python-jose", specifier = ">=3.5.0" }, { name = "python-jose", specifier = ">=3.5.0" },
{ name = "sqlalchemy", specifier = ">=2.0.49" }, { name = "sqlalchemy", specifier = ">=2.0.49" },
{ name = "strawberry-graphql", extras = ["fastapi"], specifier = ">=0.315.5" },
{ name = "ulid-py", specifier = ">=1.1.0" }, { name = "ulid-py", specifier = ">=1.1.0" },
{ name = "uvicorn", specifier = ">=0.47.0" }, { name = "uvicorn", specifier = ">=0.47.0" },
] ]
@@ -237,6 +243,18 @@ version = "1.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670, upload-time = "2010-06-27T14:35:29.538Z" } sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670, upload-time = "2010-06-27T14:35:29.538Z" }
[[package]]
name = "cross-web"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/83/b5ef04565acc065387dda3a4fbf0c4cfb6bab805c81b66b2bc5b5ac9a282/cross_web-0.6.0.tar.gz", hash = "sha256:ae90570802615365ca1a781117b43bfd0d6cd3bf611649d24c3a206a82a693c9", size = 331315, upload-time = "2026-04-13T14:29:12.718Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/a2/dab06d9b80cb76c700883186a9a2e6fd103342c9b4def4d88f5787796e17/cross_web-0.6.0-py3-none-any.whl", hash = "sha256:bdebf0c08d02f3a48cf67b6904d3a6d8fd8cab2cd905592ab96ab00b259cd582", size = 24820, upload-time = "2026-04-13T14:29:11.198Z" },
]
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "48.0.0" version = "48.0.0"
@@ -290,6 +308,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
] ]
[[package]]
name = "dotenv"
version = "0.9.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dotenv" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
]
[[package]] [[package]]
name = "ecdsa" name = "ecdsa"
version = "0.19.2" version = "0.19.2"
@@ -318,6 +347,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
] ]
[[package]]
name = "graphql-core"
version = "3.2.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/68/c5/36aa96205c3ecbb3d34c7c24189e4553c7ca2ebc7e1dd07432339b980272/graphql_core-3.2.8.tar.gz", hash = "sha256:015457da5d996c924ddf57a43f4e959b0b94fb695b85ed4c29446e508ed65cf3", size = 513181, upload-time = "2026-03-05T19:55:37.332Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/41/cb887d9afc5dabd78feefe6ccbaf83ff423c206a7a1b7aeeac05120b2125/graphql_core-3.2.8-py3-none-any.whl", hash = "sha256:cbee07bee1b3ed5e531723685369039f32ff815ef60166686e0162f540f1520c", size = 207349, upload-time = "2026-03-05T19:55:35.911Z" },
]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.5.0" version = "3.5.0"
@@ -452,6 +490,15 @@ dependencies = [
] ]
sdist = { url = "https://files.pythonhosted.org/packages/df/b5/f2cb1950dda46ac2284d6c950489fdacd0e743c2d79a347924d3cc44b86f/oss2-2.19.1.tar.gz", hash = "sha256:a8ab9ee7eb99e88a7e1382edc6ea641d219d585a7e074e3776e9dec9473e59c1", size = 298845, upload-time = "2024-10-25T11:37:46.638Z" } sdist = { url = "https://files.pythonhosted.org/packages/df/b5/f2cb1950dda46ac2284d6c950489fdacd0e743c2d79a347924d3cc44b86f/oss2-2.19.1.tar.gz", hash = "sha256:a8ab9ee7eb99e88a7e1382edc6ea641d219d585a7e074e3776e9dec9473e59c1", size = 298845, upload-time = "2024-10-25T11:37:46.638Z" }
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]] [[package]]
name = "psycopg2-binary" name = "psycopg2-binary"
version = "2.9.12" version = "2.9.12"
@@ -564,6 +611,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
] ]
[[package]]
name = "pyjwt"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@@ -576,6 +637,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
] ]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]] [[package]]
name = "python-jose" name = "python-jose"
version = "3.5.0" version = "3.5.0"
@@ -590,6 +660,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
] ]
[[package]]
name = "python-multipart"
version = "0.0.29"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.34.2" version = "2.34.2"
@@ -676,6 +755,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
] ]
[[package]]
name = "strawberry-graphql"
version = "0.315.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cross-web" },
{ name = "graphql-core" },
{ name = "packaging" },
{ name = "python-dateutil" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/9b/101940ee899959d4d220cc8beb4f41bce9c58830d1872df06f35cf92c457/strawberry_graphql-0.315.5.tar.gz", hash = "sha256:29a2f04479aba29f9f30ecdfce1ef9ee04acee3c434a4f6019249474cd649492", size = 222724, upload-time = "2026-05-14T10:46:16.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/19/e389140b3b50faf803b4817ae5a8c3c2d6737f062f4a0fe6778e929a405d/strawberry_graphql-0.315.5-py3-none-any.whl", hash = "sha256:073bc818a5f55951a9a6fbab40bbfa07c418d2c4151cc2aab24399bd14d9a51a", size = 325062, upload-time = "2026-05-14T10:46:18.672Z" },
]
[package.optional-dependencies]
fastapi = [
{ name = "fastapi" },
{ name = "python-multipart" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"