Rewrite from Go + vanilla JS to Python (FastAPI) + React (TypeScript)

- Backend: Python 3.12 with FastAPI, SQLAlchemy, boto3
- Frontend: React 18 with TypeScript, Vite build tooling
- Updated Dockerfile for multi-stage Node + Python build
- Updated CI pipeline for Python backend
- Removed old Go code (cmd/, internal/, go.mod, go.sum)
- Updated README with new tech stack documentation
This commit is contained in:
Mondo Diaz
2025-12-05 17:16:43 -06:00
parent 343f7bfc59
commit 2261bfc830
45 changed files with 2104 additions and 3359 deletions

0
backend/app/__init__.py Normal file
View File

38
backend/app/config.py Normal file
View File

@@ -0,0 +1,38 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Server
server_host: str = "0.0.0.0"
server_port: int = 8080
# Database
database_host: str = "localhost"
database_port: int = 5432
database_user: str = "orchard"
database_password: str = ""
database_dbname: str = "orchard"
database_sslmode: str = "disable"
# S3
s3_endpoint: str = ""
s3_region: str = "us-east-1"
s3_bucket: str = "orchard-artifacts"
s3_access_key_id: str = ""
s3_secret_access_key: str = ""
s3_use_path_style: bool = True
@property
def database_url(self) -> str:
sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else ""
return f"postgresql://{self.database_user}:{self.database_password}@{self.database_host}:{self.database_port}/{self.database_dbname}{sslmode}"
class Config:
env_prefix = "ORCHARD_"
case_sensitive = False
@lru_cache()
def get_settings() -> Settings:
return Settings()

25
backend/app/database.py Normal file
View File

@@ -0,0 +1,25 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from typing import Generator
from .config import get_settings
from .models import Base
settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def init_db():
"""Create all tables"""
Base.metadata.create_all(bind=engine)
def get_db() -> Generator[Session, None, None]:
"""Dependency for getting database sessions"""
db = SessionLocal()
try:
yield db
finally:
db.close()

54
backend/app/main.py Normal file
View File

@@ -0,0 +1,54 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from contextlib import asynccontextmanager
import os
from .config import get_settings
from .database import init_db
from .routes import router
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: initialize database
init_db()
yield
# Shutdown: cleanup if needed
app = FastAPI(
title="Orchard",
description="Content-Addressable Storage System",
version="1.0.0",
lifespan=lifespan,
)
# Include API routes
app.include_router(router)
# Serve static files (React build) if the directory exists
static_dir = os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "dist")
if os.path.exists(static_dir):
app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets")
@app.get("/")
async def serve_spa():
return FileResponse(os.path.join(static_dir, "index.html"))
# Catch-all for SPA routing (must be last)
@app.get("/{full_path:path}")
async def serve_spa_routes(full_path: str):
# Don't catch API routes
if full_path.startswith("api/") or full_path.startswith("health") or full_path.startswith("grove/"):
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not found")
index_path = os.path.join(static_dir, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not found")

204
backend/app/models.py Normal file
View File

@@ -0,0 +1,204 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column, String, Text, Boolean, Integer, BigInteger,
DateTime, ForeignKey, CheckConstraint, Index, JSON
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship, declarative_base
import uuid
Base = declarative_base()
class Grove(Base):
__tablename__ = "groves"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(255), unique=True, nullable=False)
description = Column(Text)
is_public = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
created_by = Column(String(255), nullable=False)
trees = relationship("Tree", back_populates="grove", cascade="all, delete-orphan")
permissions = relationship("AccessPermission", back_populates="grove", cascade="all, delete-orphan")
__table_args__ = (
Index("idx_groves_name", "name"),
Index("idx_groves_created_by", "created_by"),
)
class Tree(Base):
__tablename__ = "trees"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
grove_id = Column(UUID(as_uuid=True), ForeignKey("groves.id", ondelete="CASCADE"), nullable=False)
name = Column(String(255), nullable=False)
description = Column(Text)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
grove = relationship("Grove", back_populates="trees")
grafts = relationship("Graft", back_populates="tree", cascade="all, delete-orphan")
harvests = relationship("Harvest", back_populates="tree", cascade="all, delete-orphan")
consumers = relationship("Consumer", back_populates="tree", cascade="all, delete-orphan")
__table_args__ = (
Index("idx_trees_grove_id", "grove_id"),
Index("idx_trees_name", "name"),
{"extend_existing": True},
)
class Fruit(Base):
__tablename__ = "fruits"
id = Column(String(64), primary_key=True) # SHA256 hash
size = Column(BigInteger, nullable=False)
content_type = Column(String(255))
original_name = Column(String(1024))
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
created_by = Column(String(255), nullable=False)
ref_count = Column(Integer, default=1)
s3_key = Column(String(1024), nullable=False)
grafts = relationship("Graft", back_populates="fruit")
harvests = relationship("Harvest", back_populates="fruit")
__table_args__ = (
Index("idx_fruits_created_at", "created_at"),
Index("idx_fruits_created_by", "created_by"),
)
class Graft(Base):
__tablename__ = "grafts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tree_id = Column(UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"), nullable=False)
name = Column(String(255), nullable=False)
fruit_id = Column(String(64), ForeignKey("fruits.id"), nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
created_by = Column(String(255), nullable=False)
tree = relationship("Tree", back_populates="grafts")
fruit = relationship("Fruit", back_populates="grafts")
history = relationship("GraftHistory", back_populates="graft", cascade="all, delete-orphan")
__table_args__ = (
Index("idx_grafts_tree_id", "tree_id"),
Index("idx_grafts_fruit_id", "fruit_id"),
)
class GraftHistory(Base):
__tablename__ = "graft_history"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
graft_id = Column(UUID(as_uuid=True), ForeignKey("grafts.id", ondelete="CASCADE"), nullable=False)
old_fruit_id = Column(String(64), ForeignKey("fruits.id"))
new_fruit_id = Column(String(64), ForeignKey("fruits.id"), nullable=False)
changed_at = Column(DateTime(timezone=True), default=datetime.utcnow)
changed_by = Column(String(255), nullable=False)
graft = relationship("Graft", back_populates="history")
__table_args__ = (
Index("idx_graft_history_graft_id", "graft_id"),
)
class Harvest(Base):
__tablename__ = "harvests"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
fruit_id = Column(String(64), ForeignKey("fruits.id"), nullable=False)
tree_id = Column(UUID(as_uuid=True), ForeignKey("trees.id"), nullable=False)
original_name = Column(String(1024))
harvested_at = Column(DateTime(timezone=True), default=datetime.utcnow)
harvested_by = Column(String(255), nullable=False)
source_ip = Column(String(45))
fruit = relationship("Fruit", back_populates="harvests")
tree = relationship("Tree", back_populates="harvests")
__table_args__ = (
Index("idx_harvests_fruit_id", "fruit_id"),
Index("idx_harvests_tree_id", "tree_id"),
Index("idx_harvests_harvested_at", "harvested_at"),
)
class Consumer(Base):
__tablename__ = "consumers"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tree_id = Column(UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"), nullable=False)
project_url = Column(String(2048), nullable=False)
last_access = Column(DateTime(timezone=True), default=datetime.utcnow)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
tree = relationship("Tree", back_populates="consumers")
__table_args__ = (
Index("idx_consumers_tree_id", "tree_id"),
Index("idx_consumers_last_access", "last_access"),
)
class AccessPermission(Base):
__tablename__ = "access_permissions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
grove_id = Column(UUID(as_uuid=True), ForeignKey("groves.id", ondelete="CASCADE"), nullable=False)
user_id = Column(String(255), nullable=False)
level = Column(String(20), nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
expires_at = Column(DateTime(timezone=True))
grove = relationship("Grove", back_populates="permissions")
__table_args__ = (
CheckConstraint("level IN ('read', 'write', 'admin')", name="check_level"),
Index("idx_access_permissions_grove_id", "grove_id"),
Index("idx_access_permissions_user_id", "user_id"),
)
class APIKey(Base):
__tablename__ = "api_keys"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
key_hash = Column(String(64), unique=True, nullable=False)
name = Column(String(255), nullable=False)
user_id = Column(String(255), nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
expires_at = Column(DateTime(timezone=True))
last_used = Column(DateTime(timezone=True))
__table_args__ = (
Index("idx_api_keys_user_id", "user_id"),
Index("idx_api_keys_key_hash", "key_hash"),
)
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
action = Column(String(100), nullable=False)
resource = Column(String(1024), nullable=False)
user_id = Column(String(255), nullable=False)
details = Column(JSON)
timestamp = Column(DateTime(timezone=True), default=datetime.utcnow)
source_ip = Column(String(45))
__table_args__ = (
Index("idx_audit_logs_action", "action"),
Index("idx_audit_logs_resource", "resource"),
Index("idx_audit_logs_user_id", "user_id"),
Index("idx_audit_logs_timestamp", "timestamp"),
)

333
backend/app/routes.py Normal file
View File

@@ -0,0 +1,333 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import or_
from typing import List, Optional
import re
from .database import get_db
from .storage import get_storage, S3Storage
from .models import Grove, Tree, Fruit, Graft, Harvest, Consumer
from .schemas import (
GroveCreate, GroveResponse,
TreeCreate, TreeResponse,
FruitResponse,
GraftCreate, GraftResponse,
CultivateResponse,
ConsumerResponse,
HealthResponse,
)
router = APIRouter()
def get_user_id(request: Request) -> str:
"""Extract user ID from request (simplified for now)"""
api_key = request.headers.get("X-Orchard-API-Key")
if api_key:
return "api-user"
auth = request.headers.get("Authorization")
if auth:
return "bearer-user"
return "anonymous"
# Health check
@router.get("/health", response_model=HealthResponse)
def health_check():
return HealthResponse(status="ok")
# Grove routes
@router.get("/api/v1/groves", response_model=List[GroveResponse])
def list_groves(request: Request, db: Session = Depends(get_db)):
user_id = get_user_id(request)
groves = db.query(Grove).filter(
or_(Grove.is_public == True, Grove.created_by == user_id)
).order_by(Grove.name).all()
return groves
@router.post("/api/v1/groves", response_model=GroveResponse)
def create_grove(grove: GroveCreate, request: Request, db: Session = Depends(get_db)):
user_id = get_user_id(request)
existing = db.query(Grove).filter(Grove.name == grove.name).first()
if existing:
raise HTTPException(status_code=400, detail="Grove already exists")
db_grove = Grove(
name=grove.name,
description=grove.description,
is_public=grove.is_public,
created_by=user_id,
)
db.add(db_grove)
db.commit()
db.refresh(db_grove)
return db_grove
@router.get("/api/v1/groves/{grove_name}", response_model=GroveResponse)
def get_grove(grove_name: str, db: Session = Depends(get_db)):
grove = db.query(Grove).filter(Grove.name == grove_name).first()
if not grove:
raise HTTPException(status_code=404, detail="Grove not found")
return grove
# Tree routes
@router.get("/api/v1/grove/{grove_name}/trees", response_model=List[TreeResponse])
def list_trees(grove_name: str, db: Session = Depends(get_db)):
grove = db.query(Grove).filter(Grove.name == grove_name).first()
if not grove:
raise HTTPException(status_code=404, detail="Grove not found")
trees = db.query(Tree).filter(Tree.grove_id == grove.id).order_by(Tree.name).all()
return trees
@router.post("/api/v1/grove/{grove_name}/trees", response_model=TreeResponse)
def create_tree(grove_name: str, tree: TreeCreate, db: Session = Depends(get_db)):
grove = db.query(Grove).filter(Grove.name == grove_name).first()
if not grove:
raise HTTPException(status_code=404, detail="Grove not found")
existing = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree.name).first()
if existing:
raise HTTPException(status_code=400, detail="Tree already exists in this grove")
db_tree = Tree(
grove_id=grove.id,
name=tree.name,
description=tree.description,
)
db.add(db_tree)
db.commit()
db.refresh(db_tree)
return db_tree
# Cultivate (upload)
@router.post("/api/v1/grove/{grove_name}/{tree_name}/cultivate", response_model=CultivateResponse)
def cultivate(
grove_name: str,
tree_name: str,
request: Request,
file: UploadFile = File(...),
tag: Optional[str] = Form(None),
db: Session = Depends(get_db),
storage: S3Storage = Depends(get_storage),
):
user_id = get_user_id(request)
# Get grove and tree
grove = db.query(Grove).filter(Grove.name == grove_name).first()
if not grove:
raise HTTPException(status_code=404, detail="Grove not found")
tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first()
if not tree:
raise HTTPException(status_code=404, detail="Tree not found")
# Store file
sha256_hash, size, s3_key = storage.store(file.file)
# Create or update fruit record
fruit = db.query(Fruit).filter(Fruit.id == sha256_hash).first()
if fruit:
fruit.ref_count += 1
else:
fruit = Fruit(
id=sha256_hash,
size=size,
content_type=file.content_type,
original_name=file.filename,
created_by=user_id,
s3_key=s3_key,
)
db.add(fruit)
# Record harvest
harvest = Harvest(
fruit_id=sha256_hash,
tree_id=tree.id,
original_name=file.filename,
harvested_by=user_id,
source_ip=request.client.host if request.client else None,
)
db.add(harvest)
# Create tag if provided
if tag:
existing_graft = db.query(Graft).filter(Graft.tree_id == tree.id, Graft.name == tag).first()
if existing_graft:
existing_graft.fruit_id = sha256_hash
existing_graft.created_by = user_id
else:
graft = Graft(
tree_id=tree.id,
name=tag,
fruit_id=sha256_hash,
created_by=user_id,
)
db.add(graft)
db.commit()
return CultivateResponse(
fruit_id=sha256_hash,
size=size,
grove=grove_name,
tree=tree_name,
tag=tag,
)
# Harvest (download)
@router.get("/api/v1/grove/{grove_name}/{tree_name}/+/{ref}")
def harvest(
grove_name: str,
tree_name: str,
ref: str,
db: Session = Depends(get_db),
storage: S3Storage = Depends(get_storage),
):
# Get grove and tree
grove = db.query(Grove).filter(Grove.name == grove_name).first()
if not grove:
raise HTTPException(status_code=404, detail="Grove not found")
tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first()
if not tree:
raise HTTPException(status_code=404, detail="Tree not found")
# Resolve reference to fruit
fruit = None
# Check for explicit prefixes
if ref.startswith("fruit:"):
fruit_id = ref[6:]
fruit = db.query(Fruit).filter(Fruit.id == fruit_id).first()
elif ref.startswith("tag:") or ref.startswith("version:"):
tag_name = ref.split(":", 1)[1]
graft = db.query(Graft).filter(Graft.tree_id == tree.id, Graft.name == tag_name).first()
if graft:
fruit = db.query(Fruit).filter(Fruit.id == graft.fruit_id).first()
else:
# Try as tag name first
graft = db.query(Graft).filter(Graft.tree_id == tree.id, Graft.name == ref).first()
if graft:
fruit = db.query(Fruit).filter(Fruit.id == graft.fruit_id).first()
else:
# Try as direct fruit ID
fruit = db.query(Fruit).filter(Fruit.id == ref).first()
if not fruit:
raise HTTPException(status_code=404, detail="Artifact not found")
# Stream from S3
stream = storage.get_stream(fruit.s3_key)
filename = fruit.original_name or f"{fruit.id}"
return StreamingResponse(
stream,
media_type=fruit.content_type or "application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# Compatibility route
@router.get("/grove/{grove_name}/{tree_name}/+/{ref}")
def harvest_compat(
grove_name: str,
tree_name: str,
ref: str,
db: Session = Depends(get_db),
storage: S3Storage = Depends(get_storage),
):
return harvest(grove_name, tree_name, ref, db, storage)
# Graft routes
@router.get("/api/v1/grove/{grove_name}/{tree_name}/grafts", response_model=List[GraftResponse])
def list_grafts(grove_name: str, tree_name: str, db: Session = Depends(get_db)):
grove = db.query(Grove).filter(Grove.name == grove_name).first()
if not grove:
raise HTTPException(status_code=404, detail="Grove not found")
tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first()
if not tree:
raise HTTPException(status_code=404, detail="Tree not found")
grafts = db.query(Graft).filter(Graft.tree_id == tree.id).order_by(Graft.name).all()
return grafts
@router.post("/api/v1/grove/{grove_name}/{tree_name}/graft", response_model=GraftResponse)
def create_graft(
grove_name: str,
tree_name: str,
graft: GraftCreate,
request: Request,
db: Session = Depends(get_db),
):
user_id = get_user_id(request)
grove = db.query(Grove).filter(Grove.name == grove_name).first()
if not grove:
raise HTTPException(status_code=404, detail="Grove not found")
tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first()
if not tree:
raise HTTPException(status_code=404, detail="Tree not found")
# Verify fruit exists
fruit = db.query(Fruit).filter(Fruit.id == graft.fruit_id).first()
if not fruit:
raise HTTPException(status_code=404, detail="Fruit not found")
# Create or update graft
existing = db.query(Graft).filter(Graft.tree_id == tree.id, Graft.name == graft.name).first()
if existing:
existing.fruit_id = graft.fruit_id
existing.created_by = user_id
db.commit()
db.refresh(existing)
return existing
db_graft = Graft(
tree_id=tree.id,
name=graft.name,
fruit_id=graft.fruit_id,
created_by=user_id,
)
db.add(db_graft)
db.commit()
db.refresh(db_graft)
return db_graft
# Consumer routes
@router.get("/api/v1/grove/{grove_name}/{tree_name}/consumers", response_model=List[ConsumerResponse])
def get_consumers(grove_name: str, tree_name: str, db: Session = Depends(get_db)):
grove = db.query(Grove).filter(Grove.name == grove_name).first()
if not grove:
raise HTTPException(status_code=404, detail="Grove not found")
tree = db.query(Tree).filter(Tree.grove_id == grove.id, Tree.name == tree_name).first()
if not tree:
raise HTTPException(status_code=404, detail="Tree not found")
consumers = db.query(Consumer).filter(Consumer.tree_id == tree.id).order_by(Consumer.last_access.desc()).all()
return consumers
# Fruit by ID
@router.get("/api/v1/fruit/{fruit_id}", response_model=FruitResponse)
def get_fruit(fruit_id: str, db: Session = Depends(get_db)):
fruit = db.query(Fruit).filter(Fruit.id == fruit_id).first()
if not fruit:
raise HTTPException(status_code=404, detail="Fruit not found")
return fruit

101
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,101 @@
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel
from uuid import UUID
# Grove schemas
class GroveCreate(BaseModel):
name: str
description: Optional[str] = None
is_public: bool = True
class GroveResponse(BaseModel):
id: UUID
name: str
description: Optional[str]
is_public: bool
created_at: datetime
updated_at: datetime
created_by: str
class Config:
from_attributes = True
# Tree schemas
class TreeCreate(BaseModel):
name: str
description: Optional[str] = None
class TreeResponse(BaseModel):
id: UUID
grove_id: UUID
name: str
description: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Fruit schemas
class FruitResponse(BaseModel):
id: str
size: int
content_type: Optional[str]
original_name: Optional[str]
created_at: datetime
created_by: str
ref_count: int
class Config:
from_attributes = True
# Graft schemas
class GraftCreate(BaseModel):
name: str
fruit_id: str
class GraftResponse(BaseModel):
id: UUID
tree_id: UUID
name: str
fruit_id: str
created_at: datetime
created_by: str
class Config:
from_attributes = True
# Cultivate response (upload)
class CultivateResponse(BaseModel):
fruit_id: str
size: int
grove: str
tree: str
tag: Optional[str]
# Consumer schemas
class ConsumerResponse(BaseModel):
id: UUID
tree_id: UUID
project_url: str
last_access: datetime
created_at: datetime
class Config:
from_attributes = True
# Health check
class HealthResponse(BaseModel):
status: str
version: str = "1.0.0"

83
backend/app/storage.py Normal file
View File

@@ -0,0 +1,83 @@
import hashlib
from typing import BinaryIO, Tuple
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from .config import get_settings
settings = get_settings()
class S3Storage:
def __init__(self):
config = Config(s3={"addressing_style": "path"} if settings.s3_use_path_style else {})
self.client = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint if settings.s3_endpoint else None,
region_name=settings.s3_region,
aws_access_key_id=settings.s3_access_key_id,
aws_secret_access_key=settings.s3_secret_access_key,
config=config,
)
self.bucket = settings.s3_bucket
def store(self, file: BinaryIO) -> Tuple[str, int]:
"""
Store a file and return its SHA256 hash and size.
Content-addressable: if the file already exists, just return the hash.
"""
# Read file and compute hash
content = file.read()
sha256_hash = hashlib.sha256(content).hexdigest()
size = len(content)
# Check if already exists
s3_key = f"fruits/{sha256_hash[:2]}/{sha256_hash[2:4]}/{sha256_hash}"
if not self._exists(s3_key):
self.client.put_object(
Bucket=self.bucket,
Key=s3_key,
Body=content,
)
return sha256_hash, size, s3_key
def get(self, s3_key: str) -> bytes:
"""Retrieve a file by its S3 key"""
response = self.client.get_object(Bucket=self.bucket, Key=s3_key)
return response["Body"].read()
def get_stream(self, s3_key: str):
"""Get a streaming response for a file"""
response = self.client.get_object(Bucket=self.bucket, Key=s3_key)
return response["Body"]
def _exists(self, s3_key: str) -> bool:
"""Check if an object exists"""
try:
self.client.head_object(Bucket=self.bucket, Key=s3_key)
return True
except ClientError:
return False
def delete(self, s3_key: str) -> bool:
"""Delete an object"""
try:
self.client.delete_object(Bucket=self.bucket, Key=s3_key)
return True
except ClientError:
return False
# Singleton instance
_storage = None
def get_storage() -> S3Storage:
global _storage
if _storage is None:
_storage = S3Storage()
return _storage

11
backend/requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
psycopg2-binary==2.9.9
alembic==1.13.1
boto3==1.34.25
python-multipart==0.0.6
pydantic==2.5.3
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4