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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ APP_CORS_DOMAINS='["http://localhost:3000"]'
APP_CACHE_TTL=300
APP_CACHE_SIZE_LIMIT=1000
APP_MIN_STORAGE_MB=1024
TRUSTED_HOSTS='["127.0.0.1", 'devbin_frontend']'
APP_TRUSTED_HOSTS='["127.0.0.1", 'devbin_frontend']'
APP_KEEP_DELETED_PASTES_TIME_HOURS=336 # 2 Weeks

# Debug / Development settings
APP_SQLALCHEMY_ECHO=false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Add Delete, Edit tokens and deleted at

Revision ID: 08393764144d
Revises: 7c45e2617d61
Create Date: 2025-12-17 22:00:02.736745

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '08393764144d'
down_revision: Union[str, Sequence[str], None] = '7c45e2617d61'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('pastes', sa.Column('edit_token', sa.String(), nullable=True))
op.add_column('pastes', sa.Column('last_updated_at', sa.TIMESTAMP(timezone=True), nullable=True))
op.add_column('pastes', sa.Column('delete_token', sa.String(), nullable=True))
op.add_column('pastes', sa.Column('deleted_at', sa.TIMESTAMP(timezone=True), nullable=True))
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('pastes', 'deleted_at')
op.drop_column('pastes', 'delete_token')
op.drop_column('pastes', 'last_updated_at')
op.drop_column('pastes', 'edit_token')
# ### end Alembic commands ###
48 changes: 48 additions & 0 deletions backend/app/api/dto/paste_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
from app.config import config


class _Unset(BaseModel):
pass


UNSET = _Unset()


class PasteContentLanguage(str, Enum):
plain_text = "plain_text"

Expand Down Expand Up @@ -47,6 +54,34 @@ def validate_expires_at(cls, v):
return v


class EditPaste(BaseModel):
title: str | None = Field(
None,
min_length=1,
max_length=255,
description="The title of the paste",
)
content: str | None = Field(
None,
min_length=1,
max_length=config.MAX_CONTENT_LENGTH,
description="The content of the paste",
)
content_language: PasteContentLanguage | None = Field(
None,
description="The language of the content",
examples=[PasteContentLanguage.plain_text],
)
expires_at: datetime | None | _Unset = Field(
default=UNSET,
description="The expiration datetime. Explicitly set to null to remove expiration.",
)

def is_expires_at_set(self) -> bool:
"""Check if expires_at was explicitly provided (including None)."""
return not isinstance(self.expires_at, _Unset)


class PasteResponse(BaseModel):
id: UUID4 = Field(
description="The unique identifier of the paste",
Expand All @@ -67,6 +102,19 @@ class PasteResponse(BaseModel):
created_at: datetime = Field(
description="The creation timestamp of the paste",
)
last_updated_at: datetime | None = Field(
description="The last time the paste was updated (null = never)",
)


class CreatePasteResponse(PasteResponse):
edit_token: str = Field(
description="The token to edit the paste",
)

delete_token: str = Field(
description="The token to delete the paste",
)


class LegacyPasteResponse(BaseModel):
Expand Down
60 changes: 57 additions & 3 deletions backend/app/api/subroutes/pastes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@

from aiocache.serializers import PickleSerializer
from dependency_injector.wiring import Provide, inject
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.params import Security
from fastapi.security import APIKeyHeader
from pydantic import UUID4
from starlette.requests import Request
from starlette.responses import Response

from app.api.dto.Error import ErrorResponse
from app.api.dto.paste_dto import CreatePaste, LegacyPasteResponse, PasteResponse
from app.api.dto.paste_dto import (
CreatePaste,
CreatePasteResponse,
EditPaste,
LegacyPasteResponse,
PasteResponse,
)
from app.config import config
from app.containers import Container
from app.ratelimit import get_ip_address, limiter
Expand All @@ -21,6 +29,9 @@
max_size=config.CACHE_SIZE_LIMIT,
)

edit_token_key_header = APIKeyHeader(name="Authorization", scheme_name="Edit Token")
delete_token_key_header = APIKeyHeader(name="Authorization", scheme_name="Delete Token")


def get_exempt_key(request: Request) -> str:
auth_header = request.headers.get("Authorization")
Expand Down Expand Up @@ -123,7 +134,7 @@ async def get_paste(
)


@pastes_route.post("")
@pastes_route.post("", response_model=CreatePasteResponse)
@limiter.limit("4/minute", key_func=get_exempt_key)
@inject
async def create_paste(
Expand All @@ -134,3 +145,46 @@ async def create_paste(
return await paste_service.create_paste(
create_paste_body, request.state.user_metadata
)


@pastes_route.put("/{paste_id}")
@limiter.limit("4/minute", key_func=get_exempt_key)
@inject
async def edit_paste(
request: Request,
paste_id: UUID4,
edit_paste_body: EditPaste,
edit_token: str = Security(edit_token_key_header),
paste_service: PasteService = Depends(Provide[Container.paste_service]),
):
result = await paste_service.edit_paste(paste_id, edit_paste_body, edit_token)
if not result:
raise HTTPException(
status_code=404,
detail=ErrorResponse(
error="paste_not_found",
message=f"Paste {paste_id} not found",
).model_dump(),
)
return result


@pastes_route.delete("/{paste_id}")
@limiter.limit("4/minute", key_func=get_exempt_key)
@inject
async def delete_paste(
request: Request,
paste_id: UUID4,
delete_token: str = Security(delete_token_key_header),
paste_service: PasteService = Depends(Provide[Container.paste_service]),
):
result = await paste_service.delete_paste(paste_id, delete_token)
if not result:
raise HTTPException(
status_code=404,
detail=ErrorResponse(
error="paste_not_found",
message=f"Paste {paste_id} not found",
).model_dump(),
)
return {"message": "Paste deleted successfully"}
6 changes: 6 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ class Config(BaseSettings):
description="Minimum storage size in MB free",
)

KEEP_DELETED_PASTES_TIME_HOURS: int = Field(
default=336,
validation_alias="APP_KEEP_DELETED_PASTES_TIME_HOURS",
description="Keep deleted pastes for X hours ( Default 336 hours, 2 weeks, -1 disable, 0 instant )",
)

TRUSTED_HOSTS: list[str] = Field(
default=["127.0.0.1"],
validation_alias="APP_TRUSTED_HOSTS",
Expand Down
32 changes: 19 additions & 13 deletions backend/app/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@


@asynccontextmanager
async def _engine_resource(db_url: str, echo: bool = False) -> AsyncIterator[AsyncEngine]:
async def _engine_resource(
db_url: str, echo: bool = False
) -> AsyncIterator[AsyncEngine]:
engine = create_async_engine(db_url, echo=echo, future=True)
try:
yield engine
Expand All @@ -29,12 +31,14 @@ async def _session_resource(factory: sessionmaker) -> AsyncIterator[AsyncSession


class Container(containers.DeclarativeContainer):
wiring_config = containers.WiringConfiguration(modules=[
"app.api.routes",
"app.api.subroutes.pastes",
"app.services",
"app.dependencies.db",
])
wiring_config = containers.WiringConfiguration(
modules=[
"app.api.routes",
"app.api.subroutes.pastes",
"app.services",
"app.dependencies.db",
]
)

config = providers.Callable(get_config)
# Database engine (async) as a managed resource
Expand All @@ -54,14 +58,16 @@ class Container(containers.DeclarativeContainer):
)

# Services
from app.services.health_service import (
HealthService, # local import to avoid cycles during tooling
)
from app.services.cleanup_service import CleanupService
from app.services.health_service import HealthService
from app.services.paste_service import PasteService

health_service = providers.Factory(HealthService, session_factory)

cleanup_service = providers.Factory(
CleanupService, session_factory, config().BASE_FOLDER_PATH
)

paste_service = providers.Factory(
PasteService,
session_factory,
config().BASE_FOLDER_PATH
PasteService, session_factory, cleanup_service, config().BASE_FOLDER_PATH
)
10 changes: 9 additions & 1 deletion backend/app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,21 @@ class PasteEntity(Base):
content_path = Column(String, nullable=False)
content_language = Column(String, nullable=False, server_default="plain_text")
expires_at = Column(TIMESTAMP(timezone=True), nullable=True)
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
created_at = Column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)

content_size = Column(Integer, nullable=False)

creator_ip = Column(String)
creator_user_agent = Column(String)

edit_token = Column(String)
last_updated_at = Column(TIMESTAMP(timezone=True))

delete_token = Column(String)
deleted_at = Column(TIMESTAMP(timezone=True), nullable=True)

def __repr__(self):
return f"<Paste(id={self.id}, title='{self.title}')>"

Expand Down
Loading