diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6253f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +config.yaml +__pycache__/ +*.pyc +.venv/ +*.egg-info/ +dist/ +build/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..21170b6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY pyproject.toml . +RUN pip install --no-cache-dir . + +COPY app/ app/ +COPY config.example.yaml config.example.yaml + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..95b3556 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,33 @@ +from fastapi import Request +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from app.config import settings + + +class AuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + if not request.url.path.startswith("/api/v1"): + return await call_next(request) + + token = settings.api.token + if not token: + return JSONResponse( + status_code=500, + content={"success": False, "error": "Server configuration error"}, + ) + + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return JSONResponse( + status_code=401, + content={"success": False, "error": "Missing authentication token"}, + ) + + if auth_header.removeprefix("Bearer ") != token: + return JSONResponse( + status_code=403, + content={"success": False, "error": "Invalid authentication token"}, + ) + + return await call_next(request) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..b30b55b --- /dev/null +++ b/app/config.py @@ -0,0 +1,48 @@ +from pathlib import Path + +import yaml +from pydantic import BaseModel + + +class MinioConfig(BaseModel): + endpoint: str + bucket: str = "snapledger" + access_key: str + secret_key: str + + +class PostgresConfig(BaseModel): + host: str + port: int = 5432 + database: str = "snapledger" + user: str + password: str + + @property + def dsn(self) -> str: + return ( + f"postgresql://{self.user}:{self.password}" + f"@{self.host}:{self.port}/{self.database}" + ) + + +class ApiConfig(BaseModel): + token: str + + +class AppConfig(BaseModel): + minio: MinioConfig + postgresql: PostgresConfig + api: ApiConfig + + +def load_config(path: str = "config.yaml") -> AppConfig: + config_path = Path(path) + if not config_path.exists(): + raise FileNotFoundError(f"Configuration file not found: {path}") + with open(config_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + return AppConfig(**data) + + +settings = load_config() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..64716db --- /dev/null +++ b/app/main.py @@ -0,0 +1,20 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.auth import AuthMiddleware +from app.routers import upload + +app = FastAPI( + title="SnapLedger API", + version="0.1.0", +) + +app.add_middleware(AuthMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(upload.router, prefix="/api/v1") diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/transaction.py b/app/models/transaction.py new file mode 100644 index 0000000..36095b5 --- /dev/null +++ b/app/models/transaction.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class UploadResponse(BaseModel): + success: bool + data: dict | None = None + error: str | None = None diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/upload.py b/app/routers/upload.py new file mode 100644 index 0000000..aedc5b2 --- /dev/null +++ b/app/routers/upload.py @@ -0,0 +1,61 @@ +import logging + +from fastapi import APIRouter, File, Form, UploadFile +from fastapi.responses import JSONResponse + +from app.services import database, minio + +router = APIRouter(tags=["upload"]) +logger = logging.getLogger(__name__) + +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB + + +@router.post("/transactions/upload") +async def upload_transaction( + file: UploadFile = File(...), + original_filename: str | None = Form(None), + user_note: str | None = Form(None), +): + # Read file content + content = await file.read() + + if len(content) == 0: + return JSONResponse( + status_code=422, + content={"success": False, "error": "Missing required field: file"}, + ) + + if len(content) > MAX_FILE_SIZE: + return JSONResponse( + status_code=413, + content={"success": False, "error": "File size exceeds 10MB limit"}, + ) + + filename = original_filename or file.filename or "upload.jpg" + + # Upload to MinIO + try: + object_key = minio.upload_image(content, filename) + except Exception: + logger.exception("Failed to upload image to storage") + return JSONResponse( + status_code=503, + content={"success": False, "error": "Storage service unavailable"}, + ) + + # Insert metadata into PostgreSQL + try: + result = database.insert_transaction( + image_object_key=object_key, + original_filename=filename, + user_note=user_note if user_note else None, + ) + except Exception: + logger.exception("Failed to save transaction metadata") + return JSONResponse( + status_code=500, + content={"success": False, "error": "Failed to save transaction metadata"}, + ) + + return {"success": True, "data": result} diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/database.py b/app/services/database.py new file mode 100644 index 0000000..9c0e92d --- /dev/null +++ b/app/services/database.py @@ -0,0 +1,29 @@ +import psycopg +from psycopg.rows import dict_row + +from app.config import settings + + +def _get_conn(): + return psycopg.connect(settings.postgresql.dsn, row_factory=dict_row) + + +def insert_transaction( + image_object_key: str, + original_filename: str | None, + user_note: str | None, +) -> dict: + with _get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO transactions + (image_object_key, original_filename, user_note, process_status) + VALUES (%s, %s, %s, 'PENDING') + RETURNING id, upload_time + """, + (image_object_key, original_filename, user_note), + ) + row = cur.fetchone() + conn.commit() + return {"id": str(row["id"]), "upload_time": row["upload_time"].isoformat()} diff --git a/app/services/minio.py b/app/services/minio.py new file mode 100644 index 0000000..000b0ad --- /dev/null +++ b/app/services/minio.py @@ -0,0 +1,41 @@ +import uuid +from datetime import datetime, timezone +from pathlib import PurePosixPath + +import boto3 +from botocore.config import Config as BotoConfig + +from app.config import settings + + +def _get_client(): + return boto3.client( + "s3", + endpoint_url=settings.minio.endpoint, + aws_access_key_id=settings.minio.access_key, + aws_secret_access_key=settings.minio.secret_key, + config=BotoConfig(signature_version="s3v4"), + ) + + +def _generate_object_key(filename: str) -> str: + now = datetime.now(timezone.utc) + date_prefix = now.strftime("%Y/%m/%d") + ext = PurePosixPath(filename).suffix or ".jpg" + return f"{date_prefix}/{uuid.uuid4().hex}{ext}" + + +def upload_image(file_bytes: bytes, original_filename: str) -> str: + client = _get_client() + object_key = _generate_object_key(original_filename) + content_type = "image/jpeg" + if original_filename.lower().endswith(".png"): + content_type = "image/png" + + client.put_object( + Bucket=settings.minio.bucket, + Key=object_key, + Body=file_bytes, + ContentType=content_type, + ) + return object_key diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..0a1733b --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,18 @@ +# SnapLedger Backend Configuration +# Copy this file to config.yaml and fill in your credentials. + +minio: + endpoint: "https://minio.example.com:9000" + bucket: "snapledger" + access_key: "YOUR_MINIO_ACCESS_KEY" + secret_key: "YOUR_MINIO_SECRET_KEY" + +postgresql: + host: "localhost" + port: 5432 + database: "snapledger" + user: "snapledger_user" + password: "YOUR_POSTGRES_PASSWORD" + +api: + token: "YOUR_API_TOKEN" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2880367 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + backend: + build: . + ports: + - "8000:8000" + volumes: + - ./config.yaml:/app/config.yaml:ro + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aaa79e3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "snapledger-backend" +version = "0.1.0" +description = "SnapLedger backend API server" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.34.0", + "boto3>=1.35.0", + "psycopg[binary]>=3.2.0", + "python-multipart>=0.0.12", + "pydantic>=2.9.0", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "httpx>=0.27", +]