Skip to content
This repository was archived by the owner on Dec 31, 2025. It is now read-only.
Draft
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
5 changes: 5 additions & 0 deletions .cursor/rules/docs.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
alwaysApply: true
---

when working with this project and adding API endpoints always document them in @API_DOCS.md
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ FROM python:3.12-slim AS runtime
RUN useradd -m -u 1000 toolbox && \
chown -R toolbox:toolbox /home/toolbox && \
mkdir -p /home/toolbox/.cache/huggingface && \
chown -R toolbox:toolbox /home/toolbox/.cache/huggingface
chown -R toolbox:toolbox /home/toolbox/.cache/huggingface && \
mkdir -p /home/toolbox/site/backend/main/data/photos && \
chown -R toolbox:toolbox /home/toolbox/site/backend/main/data/photos
USER toolbox

# 3.2. Copy content
Expand Down
55 changes: 54 additions & 1 deletion backend/main/API_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,4 +293,57 @@ The Toolbox.io Telegram bot provides the same support functionality as the web i
## Authentication

- All protected endpoints require the `Authorization: Bearer <access_token>` header.
- JWT tokens are returned by `/api/auth/login` and must be stored securely on the client.
- JWT tokens are returned by `/api/auth/login` and must be stored securely on the client.

---

## Photo Storage Endpoints

All `/photos/*` endpoints are under the `/photos` prefix and require authentication (`Authorization: Bearer <access_token>`).

### GET `/photos/sync`
- **Description:** List all photo UUIDs and metadata for the authenticated user (for sync).
- **Headers:**
- `Authorization: Bearer <access_token>`
- **Response:**
```json
{
"photos": [
{"uuid": "<uuid>", "uploaded_at": "<date>T<time>Z", "filename": "<string>"}
// ...
]
}
```
- **Errors:**
- `401`: Unauthorized (invalid or missing token)

---

### POST `/photos/upload`
- **Description:** Upload an encrypted photo file with a client-generated UUID.
- **Headers:**
- `Authorization: Bearer <access_token>`
- **Request (multipart/form-data):**
- `photo_uuid`: string (UUID, required)
- `file`: binary (encrypted photo, required)
- **Response:**
`201 Created`
```json
{ "status": "success" }
```
- **Errors:**
- `400`: Photo UUID already exists for this user
- `401`: Unauthorized (invalid or missing token)

---

### GET `/photos/download/{photo_uuid}`
- **Description:** Download the encrypted photo file for the given UUID (if it belongs to the user).
- **Headers:**
- `Authorization: Bearer <access_token>`
- **Response:**
- Content-Type: `application/octet-stream`
- File download (binary)
- **Errors:**
- `404`: Photo not found or file missing
- `401`: Unauthorized (invalid or missing token)
3 changes: 2 additions & 1 deletion backend/main/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import utils
from limiter import limiter
from routes import auth, guides, core, issues, support
from routes import auth, guides, core, issues, support, photos
from live_reload import HTMLInjectorMiddleware
import live_reload

Expand Down Expand Up @@ -55,3 +55,4 @@ async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> Respon
app.include_router(issues.router, prefix="/api/issues")
app.include_router(live_reload.router)
app.include_router(support.router, prefix="/api/support")
app.include_router(photos.router, prefix="/api/photos")
11 changes: 9 additions & 2 deletions backend/main/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import re
from datetime import datetime
from sqlalchemy import Boolean, String, DateTime, Column, Integer
from sqlalchemy import Boolean, String, DateTime, Column, Integer, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func

Expand Down Expand Up @@ -110,4 +110,11 @@ class BlacklistedToken(Base):
__tablename__ = "blacklisted_tokens"
id = Column(Integer, primary_key=True, index=True)
token = Column(String(512), unique=True, nullable=False)
blacklisted_at = Column(DateTime(timezone=True), server_default=func.now())
blacklisted_at = Column(DateTime(timezone=True), server_default=func.now())

class Photo(Base):
__tablename__ = "photos"
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
uuid = Column(String(36), unique=True, index=True, nullable=False, primary_key=True) # Client-generated UUID
filename = Column(String(255), nullable=False)
uploaded_at = Column(DateTime(timezone=True), server_default=func.now())
117 changes: 117 additions & 0 deletions backend/main/routes/photos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
import os
from uuid import uuid4
from utils import FileTooLargeError, save_file
from models import Photo, User
from db.core import get_db
from routes.auth.utils import get_current_user
from limiter import limiter
from fastapi import Request
import logging

router = APIRouter()
PHOTO_DIR = "data/photos"
os.makedirs(PHOTO_DIR, exist_ok=True)

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

MAX_SIZE = 50 * 1024 * 1024 # 50MB
USER_MAX_STORAGE = 1024 * 1024 * 1024 # 1GB

def get_used_storage(photos: list[Photo]):
user_storage = 0
for p in photos:
try:
if os.path.exists(p.filename):
user_storage += os.path.getsize(p.filename)
except Exception as e:
logger.error(e);
pass
return user_storage


def delete_old_photos(usedStorage: int, photos: list[Photo], db: Session):
while usedStorage > USER_MAX_STORAGE and photos:
oldest = photos.pop(0)
try:
if os.path.exists(oldest.filename):
user_storage -= os.path.getsize(oldest.filename)
os.remove(oldest.filename)
db.delete(oldest)
db.commit()
except Exception as e:
logger.warning(f"Failed to delete old photo {oldest.uuid}: {e}")

@router.get("/sync")
def sync_photos(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
photos = db.query(Photo).filter_by(user_id=current_user.id).all()
photos_new = photos.copy()

for photo in photos:
logger.info(f"Checking {photo}")
if os.path.exists(photo.filename):
photos_new += {
"uuid": photo.uuid,
"uploaded_at": photo.uploaded_at,
"filename": photo.filename
}
else:
logger.warning(f"Photo {photo.uuid} doesn't exist")

return {"photos": photos_new}

@router.post("/upload", status_code=status.HTTP_201_CREATED)
@limiter.limit("1/minute")
async def upload_photo(
request: Request,
photo_uuid: str = Form(...),
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
filename = f"{current_user.id}_{photo_uuid}.bin"
file_path = os.path.join(PHOTO_DIR, filename)

# Calculate current user storage usage
user_photos = db.query(Photo).filter_by(user_id=current_user.id).order_by(Photo.uploaded_at).all()
user_storage = get_used_storage(user_photos)

# Read file in chunks, check size
try:
total_size = await save_file(input=file, output=file_path, max_size=MAX_SIZE)
except FileTooLargeError:
raise HTTPException(status_code=413, detail="File too large (max 50MB)")

# Enforce 1GB per-user storage limit
delete_old_photos(usedStorage=user_storage + total_size, photos=user_photos, db=db)

# Check if UUID already exists for this user
if db.query(Photo).filter_by(uuid=photo_uuid, user_id=current_user.id).first():
os.remove(file_path)
raise HTTPException(status_code=400, detail="Photo UUID already exists.")
# Save metadata, including user_id and uuid (attached server-side)
photo = Photo(uuid=photo_uuid, user_id=current_user.id, filename=filename)
db.add(photo)
db.commit()
return {"status": "success"}

@router.get("/download/{photo_uuid}")
def download_photo(
photo_uuid: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
photo = db.query(Photo).filter_by(uuid=photo_uuid, user_id=current_user.id).first()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found.")
file_path = os.path.join(PHOTO_DIR, photo.filename)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="File not found on server.")
return FileResponse(file_path, media_type="application/octet-stream", filename=photo.filename)
23 changes: 21 additions & 2 deletions backend/main/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import os
from os import PathLike
from functools import wraps
from pathlib import Path
from typing import Tuple, Optional

from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, UploadFile
from starlette.staticfiles import StaticFiles

from constants import CONTENT_PATH
Expand Down Expand Up @@ -87,4 +88,22 @@ def trim_margin(text: str, margin: str = '|') -> str:
trimmed_lines.append(line[idx + len(margin):])
else:
trimmed_lines.append(line)
return '\n'.join(trimmed_lines)
return '\n'.join(trimmed_lines)

class FileTooLargeError(Exception): pass

async def save_file(input: UploadFile, output: str | PathLike[str], max_size: int = 0, chunk_size: int = 1024 * 1024):
total_size = 0
with open(output, "wb") as out_file:
while True:
chunk = await input.read(chunk_size)
if not chunk:
break
total_size += len(chunk)
if max_size != 0 and total_size > max_size:
out_file.close()
os.remove(output)
raise FileTooLargeError()
out_file.write(chunk)

return total_size
11 changes: 11 additions & 0 deletions frontend/css/common/_animations.scss
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,15 @@
opacity: 1;
transform: translateY(0);
}
}

@keyframes modalExpand {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
Loading