Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
POSTGRES_USER=admin
POSTGRES_PASSWORD=admin
POSTGRES_DB=main_db
JWT_SECRET_KEY=change-me-in-production
COOKIE_SECURE=false
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost
VITE_API_BASE_URL=/api
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,6 @@ cython_debug/

backend/uploads/*
!backend/uploads/README.md
backend/cloud_notes.db
!frontend/src/lib/
!frontend/src/lib/**
92 changes: 75 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ⚙️ Cloud_Notes_api
# ⚙️ Cloud_Notes

Async cloud notes backend powered by FastAPI + PostgreSQL. Clean architecture, authentication, and containerization out of the box.
Full-stack premium writing workspace powered by FastAPI, PostgreSQL, React, Tiptap, and Docker. Includes registration, authorization, rich text editing, autosave, note organization, and image attachments in a polished browser UI.

---

Expand All @@ -12,13 +12,16 @@ Async cloud notes backend powered by FastAPI + PostgreSQL. Clean architecture, a
[![Docker](https://img.shields.io/badge/docker-grey?style=for-the-badge&logo=docker)](https://docker.com)
[![JWT](https://img.shields.io/badge/JWT-grey?style=for-the-badge&logo=jsonwebtokens)](https://www.jwt.io)
[![Pydantic](https://img.shields.io/badge/Pydantic-grey?style=for-the-badge&logo=pydantic)](https://pydantic.dev)
[![React](https://img.shields.io/badge/react-grey?style=for-the-badge&logo=react)](https://react.dev)
[![Vite](https://img.shields.io/badge/vite-grey?style=for-the-badge&logo=vite)](https://vite.dev)

- **Backend Framework:** [FastAPI](https://tiangolo.com) (Asynchronous, High performance)
- **Database Engine:** [PostgreSQL](https://postgresql.org)
- **ORM:** [SQLAlchemy 2.0](https://sqlalchemy.org) (Async extension with `asyncpg`)
- **Data Validation:** [Pydantic v2](https://pydantic.dev)
- **Security:** JWT (JSON Web Tokens) inside secure **HttpOnly Cookies** + `bcrypt` password hashing
- **Environment:** [Docker](https://docker.com) / [OrbStack](https://orbstack.dev) for full containerization
- **Frontend:** [React 19](https://react.dev) + [Vite](https://vite.dev) + [Tiptap](https://tiptap.dev)
- **Environment:** [Docker](https://docker.com) / [OrbStack](https://orbstack.dev) with production-ready containers

## 🚀 Quick Start (Docker)

Expand All @@ -30,36 +33,79 @@ Make sure you have **Docker** or **OrbStack** running on your system. You don't
cd Cloud_Notes_api
```

2. **Launch the environment:**
This command automatically creates the database, runs migrations (tables initialization), and starts the backend server:
2. **Prepare environment variables:**
```bash
cp .env.example .env
```

3. **Launch the production-like environment:**
This command builds the backend image, builds the frontend static bundle, starts PostgreSQL, and serves the app through Nginx:
```bash
docker compose up --build
```

3. **Explore the API:**
Once the terminal prints `Uvicorn running on http://0.0.0.0`, open your browser at:
- **Interactive API Docs (Swagger UI):** `http://localhost:8000/docs`
4. **Open the apps:**
- **Web UI:** `http://localhost:5173`
- **API Docs (Swagger UI):** `http://localhost:8000/docs`

---

## 🐳 Container Notes

- `frontend` is built as static assets and served by **Nginx**
- browser API calls go through `/api`, which Nginx proxies to the FastAPI container
- `server` stores uploaded attachments in a persistent Docker volume
- `db` stores PostgreSQL data in a persistent Docker volume
- for production, replace `JWT_SECRET_KEY` and set `COOKIE_SECURE=true` behind HTTPS

## 🧪 Development Mode

For hot reload during active development:

```bash
docker compose -f docker-compose.dev.yml up --build
```

This mode differs from the default stack:

- `frontend` runs Vite dev server on `http://localhost:5173`
- `server` runs `uvicorn --reload`
- source folders are mounted into the containers
- PostgreSQL still runs in Docker, so no local DB install is required

## 📂 Project Structure

```text
Cloud_Notes_api/
Cloud_Notes/
├── backend/
│ ├── app/
│ │ ├── api/ # Modular API Routers (Separation of Concerns)
│ │ │ ├── notes.py # Notes CRUD logic (Markdown supported)
│ │ │ ├── attachments.py # Authenticated image upload & download
│ │ │ ├── notes.py # Notes CRUD logic
│ │ │ ├── system.py # Health checks and monitoring
│ │ │ └── users.py # Registration & Authentication
│ │ │ └── users.py # Registration, login, current user
│ │ ├── database.py # Async SQLAlchemy engine & session setup
│ │ ├── dependencies.py # Secure request filters (Token decoding & User fetch)
│ │ ├── main.py # Application entrypoint & Lifespan setup
│ │ ├── models.py # SQLAlchemy DB models (User, Note)
│ │ ├── dependencies.py # Cookie auth and current user lookup
│ │ ├── main.py # FastAPI entrypoint, startup, CORS
│ │ ├── models.py # SQLAlchemy DB models (User, Note, Attachment)
│ │ ├── schemas.py # Pydantic validation DTOs
│ │ └── utils.py # Hashing & JWT utility functions
│ │ └── utils.py # Password hashing & JWT utility functions
│ ├── Dockerfile
│ ├── requirements.txt
│ └── .dockerignore
├── frontend/
│ ├── src/
│ │ ├── App.tsx # Auth flow and premium notes workspace UI
│ │ ├── App.css # Main application layout and components
│ │ ├── index.css # Global tokens, typography, background
│ │ ├── lib/api.ts # Browser API client with cookie credentials
│ │ └── types.ts # Shared frontend models
│ ├── Dockerfile
│ └── requirements.txt
│ ├── nginx.conf
│ ├── package.json
│ └── vite.config.ts
├── .env.example
├── docker-compose.dev.yml
├── docker-compose.yml
└── README.md
```
Expand All @@ -69,5 +115,17 @@ Cloud_Notes_api/
## 🔒 Security Features

- **XSS Protection:** Access tokens are strictly transmitted via **HttpOnly** cookies, preventing malicious JavaScript from stealing sessions.
- **CSRF Mitigation:** Cookie management utilizes `samesite="lax"` policy configuration.
- **Cookie Policy:** Session cookies use `samesite="lax"` and support `secure=true` through environment configuration.
- **Credential Safety:** Passwords are encrypted with a slow `bcrypt` hashing context. No raw credentials ever enter the database.
- **Frontend Auth Flow:** Browser requests are sent with credentials and validated through `/users/me`.

## ✨ Product Features

- Registration and login with secure cookie-based sessions
- Rich text editor with visual formatting toolbar
- Autosave without cursor reset during editing
- Pinned, favorite, archived, and searchable notes
- Drag-and-drop image uploads inside the editor
- Slash command palette for fast block insertion
- Focus mode for distraction-free writing
- Responsive premium workspace for desktop and mobile
10 changes: 10 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.mypy_cache/
.venv/
venv/
uploads/
cloud_notes.db
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ COPY . /code/

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
26 changes: 18 additions & 8 deletions backend/app/api/attachments.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from pathlib import Path

from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
from fastapi.responses import FileResponse

from sqlalchemy.ext.asyncio import AsyncSession
Expand All @@ -12,16 +14,24 @@

ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
MAX_FILE_SIZE = 25 * 1024 * 1024
UPLOADS_DIR = Path(__file__).resolve().parents[2] / "uploads"

attachments_router = APIRouter(prefix="/attachments", tags=["Attachments"])


@attachments_router.post("")
async def upload_attachments(
request: Request,
file: UploadFile,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
if not file.filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Filename is required."
)

extension = os.path.splitext(file.filename)[1].lower()

if extension not in ALLOWED_EXTENSIONS:
Expand All @@ -30,17 +40,18 @@ async def upload_attachments(
detail=f"Unsupported file type. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}"
)

if file.size > MAX_FILE_SIZE:
file_bytes = await file.read()
if len(file_bytes) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File is too large. Maximum allowed size is 25 MB."
)

file_uuid = uuid.uuid4()
saved_name = f"{file_uuid}{extension}"
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)

file_path = f"uploads/{saved_name}"
file_bytes = await file.read()
file_path = UPLOADS_DIR / saved_name

with open(file_path, "wb") as buffer:
buffer.write(file_bytes)
Expand All @@ -54,7 +65,7 @@ async def upload_attachments(
db.add(new_attachment)
await db.commit()

return {"url": f"http://localhost:8000/attachments/download/{file_uuid}"}
return {"url": str(request.url_for("download_attachment", file_uuid=str(file_uuid)))}

@attachments_router.get("/download/{file_uuid}")
async def download_attachment(
Expand All @@ -69,12 +80,11 @@ async def download_attachment(
if attachment is None or attachment.creator_id != current_user.id:
raise HTTPException(status_code=404, detail="File not found")

files = [f for f in os.listdir("uploads") if f.startswith(file_uuid)]
files = [f for f in os.listdir(UPLOADS_DIR) if f.startswith(file_uuid)]
if not files:
raise HTTPException(status_code=404, detail="File on disk not found")

file_name = files[0]
file_path = f"uploads/{file_name}"
file_path = UPLOADS_DIR / file_name

return FileResponse(file_path)

82 changes: 70 additions & 12 deletions backend/app/api/notes.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,90 @@
import json

from fastapi import APIRouter, Depends, HTTPException

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, case, desc

from ..models import User, Note
from ..database import get_db
from ..dependencies import get_current_user
from ..schemas import NoteCreate, NoteUpdate
from ..schemas import NoteCreate, NoteUpdate, NotePublic

notes_router = APIRouter(prefix="/notes", tags=["Notes"])

@notes_router.post("")
def normalize_tags(tags: list[str]) -> list[str]:
seen: set[str] = set()
normalized: list[str] = []

for tag in tags:
clean = tag.strip().lower()
if not clean or clean in seen:
continue
seen.add(clean)
normalized.append(clean[:24])

return normalized[:12]


def serialize_note(note: Note) -> NotePublic:
try:
tags = json.loads(note.tags or "[]")
except json.JSONDecodeError:
tags = []

return NotePublic(
id=note.id,
title=note.title,
text=note.text,
summary=note.summary,
tags=tags if isinstance(tags, list) else [],
is_pinned=note.is_pinned,
is_favorite=note.is_favorite,
is_archived=note.is_archived,
created_time=note.created_time,
edit_time=note.edit_time,
creator_id=note.creator_id,
)


@notes_router.post("", response_model=NotePublic)
async def create_note(userdata: NoteCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
note = Note(title=userdata.title, text=userdata.text, creator_id=current_user.id)
note = Note(
title=userdata.title,
text=userdata.text,
summary=userdata.summary,
tags=json.dumps(normalize_tags(userdata.tags)),
is_pinned=userdata.is_pinned,
is_favorite=userdata.is_favorite,
is_archived=userdata.is_archived,
creator_id=current_user.id,
)
db.add(note)
await db.commit()
await db.refresh(note)
return note
return serialize_note(note)

@notes_router.get("")
@notes_router.get("", response_model=list[NotePublic])
async def get_notes(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
query = select(Note).where(Note.creator_id == current_user.id)
query = (
select(Note)
.where(Note.creator_id == current_user.id)
.order_by(
desc(case((Note.is_pinned.is_(True), 1), else_=0)),
desc(Note.edit_time),
)
)
result = await db.execute(query)
return result.scalars().all()
return [serialize_note(note) for note in result.scalars().all()]

@notes_router.get("/{note_id}")
@notes_router.get("/{note_id}", response_model=NotePublic)
async def get_note(note_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
query = select(Note).where(Note.id == note_id, Note.creator_id == current_user.id)
result = await db.execute(query)
note = result.scalar_one_or_none()
if note is None:
raise HTTPException(status_code=404, detail="Note not found")
return note
return serialize_note(note)

@notes_router.delete("/{note_id}")
async def delete_note(note_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
Expand All @@ -44,7 +97,7 @@ async def delete_note(note_id: int, db: AsyncSession = Depends(get_db), current_
await db.commit()
return {"message": "Note deleted successfully"}

@notes_router.put("/{note_id}")
@notes_router.put("/{note_id}", response_model=NotePublic)
async def update_note(
note_id: int,
userdata: NoteUpdate,
Expand All @@ -60,7 +113,12 @@ async def update_note(

note.title = userdata.title
note.text = userdata.text
note.summary = userdata.summary
note.tags = json.dumps(normalize_tags(userdata.tags))
note.is_pinned = userdata.is_pinned
note.is_favorite = userdata.is_favorite
note.is_archived = userdata.is_archived

await db.commit()
await db.refresh(note)
return note
return serialize_note(note)
Loading