Skip to content
Merged
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
14 changes: 10 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: Cloud Notes Continuous Integration

on:
push:
branches: [ main ]
branches: [ main, develop ]
pull_request:
branches: [ main ]
branches: [ main, develop ]

jobs:
test:
Expand All @@ -30,12 +30,18 @@ jobs:
- name: Install Linting Tools
run: |
python -m pip install --upgrade pip
pip install flake8
pip install flake8 -r backend/requirements.txt

- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 backend/app backend/migrations backend/tests \
--count --select=E9,F63,F7,F82 --show-source --statistics

- name: Run backend tests
env:
PYTHONPATH: backend
run: python -m unittest discover -s backend/tests -v

- name: Validate Docker Compose
run: docker compose config --quiet
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Self-hosted cloud notes with a focused React editor, FastAPI backend, PostgreSQL
[![Caddy](https://img.shields.io/badge/caddy-grey?style=for-the-badge&logo=caddy)](https://caddyserver.com)

- **Frontend:** React 19, TypeScript, Vite, TipTap, and Lucide icons
- **Backend:** FastAPI, Pydantic, and async SQLAlchemy 2.0
- **Backend:** FastAPI, Pydantic, async SQLAlchemy 2.0, and Redis pub/sub
- **Database:** PostgreSQL 16
- **Authentication:** JWT in HttpOnly cookies with bcrypt password hashing
- **Web server:** Caddy providing automatic HTTPS in front of the internal Nginx service
Expand Down Expand Up @@ -63,7 +63,7 @@ Docker or OrbStack is the only requirement. Python, Node.js, Nginx, and PostgreS
- Application: `https://notes.example.com`
- API documentation: `https://notes.example.com/api/docs`

Caddy obtains and renews the public TLS certificate automatically. PostgreSQL, FastAPI, Nginx, and the Vite build are reachable only through the private Docker network.
Caddy obtains and renews the public TLS certificate automatically. PostgreSQL, Redis, FastAPI, Nginx, and the Vite build are reachable only through the private Docker network.

## 🧪 Local HTTP Testing

Expand Down Expand Up @@ -138,6 +138,20 @@ Apply configuration changes with:
docker compose up -d --build
```

Database migrations run automatically before the API starts. To inspect or apply
them manually, use:

```bash
docker compose run --rm server alembic current
docker compose run --rm server alembic upgrade head
```

Create a migration after changing SQLAlchemy models with:

```bash
docker compose run --rm server alembic revision --autogenerate -m "describe change"
```

## ✅ Production Checklist

Before exposing the application to the internet:
Expand Down Expand Up @@ -193,6 +207,7 @@ Before exposing the application to the internet:
| Nginx frontend | 80 | Not published | Docker network only |
| FastAPI backend | 8000 | Not published | Docker network only |
| PostgreSQL | 5432 | Not published | Docker network only |
| Redis | 6379 | Not published | Docker network only |

Do not forward ports 5432, 8000, or the former frontend port 8080 from the router.

Expand Down
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ USER appuser

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]
CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=*"]
37 changes: 37 additions & 0 deletions backend/alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[alembic]
script_location = migrations
prepend_sys_path = .

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
52 changes: 37 additions & 15 deletions backend/app/api/notes.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import json
import re

from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, case, desc
Expand Down Expand Up @@ -31,12 +30,12 @@ def normalize_tags(tags: list[str]) -> list[str]:

return normalized[:12]

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

def event_source(request: Request) -> str | None:
client_id = request.headers.get("X-Client-Id", "").strip()
return client_id[:128] or None

def serialize_note(note: Note) -> NotePublic:
normalized_text = LEGACY_ATTACHMENT_URL.sub(
"/api/attachments/download/",
note.text or "",
Expand All @@ -47,7 +46,7 @@ def serialize_note(note: Note) -> NotePublic:
title=note.title,
text=normalized_text,
summary=note.summary,
tags=tags if isinstance(tags, list) else [],
tags=note.tags if isinstance(note.tags, list) else [],
is_pinned=note.is_pinned,
is_favorite=note.is_favorite,
is_archived=note.is_archived,
Expand All @@ -59,12 +58,17 @@ def serialize_note(note: Note) -> NotePublic:


@notes_router.post("", response_model=NotePublic)
async def create_note(userdata: NoteCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
async def create_note(
userdata: NoteCreate,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
note = Note(
title=userdata.title,
text=userdata.text,
summary=userdata.summary,
tags=json.dumps(normalize_tags(userdata.tags)),
tags=normalize_tags(userdata.tags),
is_pinned=userdata.is_pinned,
is_favorite=userdata.is_favorite,
is_archived=userdata.is_archived,
Expand All @@ -76,7 +80,11 @@ async def create_note(userdata: NoteCreate, db: AsyncSession = Depends(get_db),
result = serialize_note(note)
await realtime_hub.publish(
current_user.id,
{"type": "note_created", "note": result.model_dump(mode="json")},
{
"type": "note_created",
"note": result.model_dump(mode="json"),
"source_client_id": event_source(request),
},
)
return result

Expand All @@ -103,7 +111,12 @@ async def get_note(note_id: int, db: AsyncSession = Depends(get_db), current_use
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)):
async def delete_note(
note_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = select(Note).where(Note.creator_id == current_user.id, Note.id == note_id)
result = await db.execute(query)
note = result.scalar_one_or_none()
Expand All @@ -113,14 +126,19 @@ async def delete_note(note_id: int, db: AsyncSession = Depends(get_db), current_
await db.commit()
await realtime_hub.publish(
current_user.id,
{"type": "note_deleted", "note_id": note_id},
{
"type": "note_deleted",
"note_id": note_id,
"source_client_id": event_source(request),
},
)
return {"message": "Note deleted successfully"}

@notes_router.put("/{note_id}", response_model=NotePublic)
async def update_note(
note_id: int,
userdata: NoteUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
Expand All @@ -145,7 +163,7 @@ 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.tags = normalize_tags(userdata.tags)
note.is_pinned = userdata.is_pinned
note.is_favorite = userdata.is_favorite
note.is_archived = userdata.is_archived
Expand All @@ -155,6 +173,10 @@ async def update_note(
result = serialize_note(note)
await realtime_hub.publish(
current_user.id,
{"type": "note_updated", "note": result.model_dump(mode="json")},
{
"type": "note_updated",
"note": result.model_dump(mode="json"),
"source_client_id": event_source(request),
},
)
return result
11 changes: 9 additions & 2 deletions backend/app/api/system.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import logging

from fastapi import APIRouter, Depends, HTTPException

from sqlalchemy.ext.asyncio import AsyncSession
Expand All @@ -6,11 +8,16 @@
from ..database import get_db

system_router = APIRouter(prefix="/system", tags=["System"])
logger = logging.getLogger(__name__)

@system_router.get("/health-check")
async def health_check(db: AsyncSession = Depends(get_db)):
try:
await db.execute(text("""SELECT 1"""))
return {"status": "ok", "database": "connected", "message": "Health check successful"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
except Exception as exception:
logger.exception("Database health check failed")
raise HTTPException(
status_code=503,
detail="Database is unavailable",
) from exception
19 changes: 5 additions & 14 deletions backend/app/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,20 @@
from sqlalchemy import select

import os
import time

from ..schemas import Register, Login, ThemeUpdate, UserPublic
from ..database import get_db
from ..models import User
from ..dependencies import get_current_user
from ..utils import hash_password, verify_password, create_access_token
from ..realtime import realtime_hub
from ..rate_limit import login_rate_limiter

user_router = APIRouter(prefix="/users", tags=["Users"])

COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
REGISTRATION_ENABLED = os.getenv("REGISTRATION_ENABLED", "true").lower() == "true"
LOGIN_WINDOW_SECONDS = 60
LOGIN_MAX_ATTEMPTS = 5
# PRODUCTION: configure a shorter lifetime in .env when persistent login is not required.
SESSION_MAX_AGE_SECONDS = int(os.getenv("SESSION_MAX_AGE_SECONDS", str(10 * 365 * 24 * 60 * 60)))
login_attempts: dict[str, list[float]] = {}

@user_router.post("/register", response_model=UserPublic)
async def register(userdata:Register, db: AsyncSession = Depends(get_db)):
Expand All @@ -44,12 +40,7 @@ async def register(userdata:Register, db: AsyncSession = Depends(get_db)):
@user_router.post("/login")
async def login(userdata:Login, request: Request, response:Response, db: AsyncSession = Depends(get_db)):
client_id = request.client.host if request.client else "unknown"
now = time.monotonic()
recent_attempts = [
attempt for attempt in login_attempts.get(client_id, [])
if now - attempt < LOGIN_WINDOW_SECONDS
]
if len(recent_attempts) >= LOGIN_MAX_ATTEMPTS:
if await login_rate_limiter.is_blocked(client_id):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Try again in one minute.",
Expand All @@ -59,14 +50,14 @@ async def login(userdata:Login, request: Request, response:Response, db: AsyncSe
result = await db.execute(query)
user = result.scalar_one_or_none()
if user is None:
login_attempts[client_id] = [*recent_attempts, now]
await login_rate_limiter.record_failure(client_id)
raise HTTPException(status_code=401, detail="Invalid credentials")

if not verify_password(userdata.password, user.pass_hash):
login_attempts[client_id] = [*recent_attempts, now]
await login_rate_limiter.record_failure(client_id)
raise HTTPException(status_code=401, detail="Invalid credentials")

login_attempts.pop(client_id, None)
await login_rate_limiter.clear(client_id)

token = create_access_token(user_id=user.id)

Expand Down
26 changes: 9 additions & 17 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,24 @@
import os
import asyncio
import shutil
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware

from .database import Base, engine
from .models import User, Note
from .database import engine
from .api.users import user_router
from .api.notes import notes_router
from .api.system import system_router
from .api.attachments import attachments_router
from .realtime import realtime_hub
from .rate_limit import login_rate_limiter
from .utils import decode_access_token

@asynccontextmanager
async def lifespan(app: FastAPI):
retries = 5
while retries > 0:
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
print("Successfully connected to the database and created tables!")
break
except Exception as e:
retries -= 1
print(f"Database is not ready yet. Retrying in 2 seconds... ({retries} retries left)")
if retries == 0:
raise RuntimeError("Could not connect to the database") from e
await asyncio.sleep(2)

redis_url = os.getenv("REDIS_URL")
await realtime_hub.start(redis_url)
await login_rate_limiter.start(redis_url)
upload_dir = Path(os.getenv("UPLOAD_DIR", "uploads"))
legacy_upload_dir = Path("/legacy-uploads")
upload_dir.mkdir(parents=True, exist_ok=True)
Expand All @@ -43,6 +31,8 @@ async def lifespan(app: FastAPI):
try:
yield
finally:
await login_rate_limiter.stop()
await realtime_hub.stop()
await engine.dispose()

app = FastAPI(
Expand Down Expand Up @@ -85,6 +75,8 @@ async def realtime_events(websocket: WebSocket):
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
realtime_hub.disconnect(user_id, websocket)

@app.get("/")
Expand Down
Loading
Loading