diff --git a/.env.example b/.env.example index 27f78ac..8d76e2d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/alembic/versions/08393764144d_add_delete_edit_tokens_and_deleted_at.py b/backend/alembic/versions/08393764144d_add_delete_edit_tokens_and_deleted_at.py new file mode 100644 index 0000000..a1add1a --- /dev/null +++ b/backend/alembic/versions/08393764144d_add_delete_edit_tokens_and_deleted_at.py @@ -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 ### diff --git a/backend/app/api/dto/paste_dto.py b/backend/app/api/dto/paste_dto.py index f2dd914..a72dcdb 100644 --- a/backend/app/api/dto/paste_dto.py +++ b/backend/app/api/dto/paste_dto.py @@ -7,6 +7,13 @@ from app.config import config +class _Unset(BaseModel): + pass + + +UNSET = _Unset() + + class PasteContentLanguage(str, Enum): plain_text = "plain_text" @@ -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", @@ -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): diff --git a/backend/app/api/subroutes/pastes.py b/backend/app/api/subroutes/pastes.py index b424ecb..52a3a4e 100644 --- a/backend/app/api/subroutes/pastes.py +++ b/backend/app/api/subroutes/pastes.py @@ -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 @@ -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") @@ -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( @@ -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"} diff --git a/backend/app/config.py b/backend/app/config.py index 01ee71f..c8b8357 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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", diff --git a/backend/app/containers.py b/backend/app/containers.py index e201a34..46605e1 100644 --- a/backend/app/containers.py +++ b/backend/app/containers.py @@ -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 @@ -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 @@ -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 ) diff --git a/backend/app/db/models.py b/backend/app/db/models.py index d3f72bc..d94845f 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -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"" diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py new file mode 100644 index 0000000..ebb644c --- /dev/null +++ b/backend/app/services/cleanup_service.py @@ -0,0 +1,195 @@ +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Coroutine + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from app.config import config +from app.db.models import PasteEntity + + +class CleanupService: + def __init__( + self, + session_maker: sessionmaker[AsyncSession], # pyright: ignore[reportInvalidTypeArguments] + paste_base_folder_path: str = "", + ): + self.session_maker: sessionmaker[AsyncSession] = session_maker + self.paste_base_folder_path: str = paste_base_folder_path + self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) + self._cleanup_task: asyncio.Task[Coroutine[None, None, None]] | None = None + self._lock_file: Path = Path(".cleanup.lock") + + def start_cleanup_worker(self): + """Start the background cleanup worker""" + self.logger.info("Starting cleanup worker") + if self._cleanup_task is not None: + return # Already running + + self._cleanup_task = asyncio.create_task(self._cleanup_loop()) + self.logger.info("Background cleanup worker started") + + async def stop_cleanup_worker(self): + """Stop the background cleanup worker""" + if self._cleanup_task is not None: + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + self._cleanup_task = None + self._release_lock() + self.logger.info("Background cleanup worker stopped") + + async def _cleanup_loop(self): + """Main cleanup loop that runs every 5 minutes""" + # Wait for 5 minutes and retry + while not self._acquire_lock(): + await asyncio.sleep(300) + + while True: + self._touch_lock() + try: + self.logger.info("Cleaning up expired pastes") + # Try to acquire lock (only one worker can run cleanup) + await self._cleanup_expired_pastes() + if config.KEEP_DELETED_PASTES_TIME_HOURS != -1: + self._touch_lock() + await self._cleanup_deleted_pastes() + + # Wait 5 minutes before next run + await asyncio.sleep(600) + except Exception as exc: + self.logger.error("Error in cleanup loop: %s", exc) + await asyncio.sleep(60) # Retry after 1 minute on error + + def _touch_lock(self): + self._lock_file.touch() + + def _acquire_lock(self) -> bool: + """Try to acquire cleanup lock""" + try: + if self._lock_file.exists(): + # Check if lock is stale (older than 15 minutes) + lock_time = self._lock_file.stat().st_mtime + if datetime.now().timestamp() - lock_time < 900: # 15 minutes + return False # Lock still valid + + # Create or update lock file + self._touch_lock() + self.logger.info("Cleanup lock acquired") + return True + except Exception as exc: + self.logger.error("Failed to acquire cleanup lock: %s", exc) + return False + + def _release_lock(self): + """Release cleanup lock""" + try: + if self._lock_file.exists(): + self._lock_file.unlink() + except Exception as exc: + self.logger.error("Failed to release cleanup lock: %s", exc) + + async def _cleanup_expired_pastes(self): + """Remove expired pastes and their files""" + from app.api.subroutes.pastes import cache + + try: + async with self.session_maker() as session: + current_time = datetime.now(tz=timezone.utc) + # Get expired paste IDs + stmt = select(PasteEntity.id, PasteEntity.content_path).where( + PasteEntity.expires_at < current_time + ) + result = await session.execute(stmt) + expired_pastes = result.fetchall() + + if not expired_pastes: + return + + error: bool = False + # Delete from database and Files + for paste_id, content_path in expired_pastes: + delete_stmt = delete(PasteEntity).where(PasteEntity.id == paste_id) + file_path = Path(self.paste_base_folder_path) / content_path + try: + if file_path.exists(): + file_path.unlink() + await session.execute(delete_stmt) + await session.commit() + await cache.delete(paste_id) + except Exception as exc: + error = True + self.logger.error( + "Failed to remove file %s: %s", file_path, exc + ) + if not error: + logging.info("Successfully cleaned up expired pastes") + else: + logging.info("Successfully cleaned up expired pastes, with errors.") + except Exception as exc: + self.logger.error("Failed to cleanup expired pastes: %s", exc) + + async def _cleanup_deleted_pastes(self): + """Remove deleted pastes that have been marked for deletion beyond the configured time""" + if config.KEEP_DELETED_PASTES_TIME_HOURS == -1: + self.logger.info( + "Skipping deletion of deleted pastes, because KEEP_DELETED_PASTES_TIME_HOURS is -1" + ) + return + self.logger.info("Cleaning up deleted pastes") + from app.api.subroutes.pastes import cache + + try: + async with self.session_maker() as session: + current_time = datetime.now(tz=timezone.utc) + delete_time_threshold = current_time.replace(microsecond=0) - timedelta( + hours=config.KEEP_DELETED_PASTES_TIME_HOURS + ) + + # Get deleted paste IDs + stmt = select(PasteEntity.id, PasteEntity.content_path).where( + PasteEntity.deleted_at.isnot(None) + & (PasteEntity.deleted_at < delete_time_threshold) + ) + result = await session.execute(stmt) + deleted_pastes = result.fetchall() + + if not deleted_pastes: + return + + error: bool = False + # Delete from database and files + for paste_id, content_path in deleted_pastes: + self.logger.info("Cleaning up deleted pastes: %s", paste_id) + delete_stmt = delete(PasteEntity).where(PasteEntity.id == paste_id) + file_path = Path(self.paste_base_folder_path) / content_path + try: + if file_path.exists(): + file_path.unlink() + await session.execute(delete_stmt) + await session.commit() + await cache.delete(paste_id) + self.logger.info( + "Successfully Cleaned up deleted pastes: %s", paste_id + ) + except Exception as exc: + error = True + self.logger.error( + "Failed to remove file %s: %s", file_path, exc + ) + if not error: + self.logger.info( + "Successfully Cleaned up deleted pastes, with no errors" + ) + else: + self.logger.info( + "Successfully Cleaned up deleted pastes, with possible errors" + ) + except Exception as exc: + self.logger.error("Failed to cleanup deleted pastes: %s", exc) diff --git a/backend/app/services/paste_service.py b/backend/app/services/paste_service.py index 0b1e88d..1e4a070 100644 --- a/backend/app/services/paste_service.py +++ b/backend/app/services/paste_service.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone from os import path from pathlib import Path -from typing import Coroutine, final +from typing import Coroutine import aiofiles from aiofiles import os @@ -17,10 +17,11 @@ from sqlalchemy import delete, or_, select from sqlalchemy.ext.asyncio.session import AsyncSession from sqlalchemy.orm import sessionmaker -from sqlalchemy.util import md5_hex from app.api.dto.paste_dto import ( CreatePaste, + CreatePasteResponse, + EditPaste, LegacyPasteResponse, PasteContentLanguage, PasteResponse, @@ -28,128 +29,27 @@ from app.api.dto.user_meta_data import UserMetaData from app.config import config from app.db.models import PasteEntity +from app.services.cleanup_service import CleanupService class PasteService: def __init__( self, - session: sessionmaker[AsyncSession], # pyright: ignore[reportInvalidTypeArguments] + session: sessionmaker[AsyncSession], + cleanup_service: CleanupService, paste_base_folder_path: str = "", ): - self.session_maker: sessionmaker[AsyncSession] = session # pyright: ignore[reportInvalidTypeArguments] + self.session_maker: sessionmaker[AsyncSession] = session self.paste_base_folder_path: str = ( paste_base_folder_path # if it is in a subfolder of the project ) self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) self._cleanup_task: asyncio.Task[Coroutine[None, None, None]] | None = None self._lock_file: Path = Path(".cleanup.lock") + self._cleanup_service: CleanupService = cleanup_service - def start_cleanup_worker(self): - """Start the background cleanup worker""" - self.logger.info("Starting cleanup worker") - if self._cleanup_task is not None or self._lock_file.exists(): - return # Already running - _ = self._acquire_lock() - self._cleanup_task = asyncio.create_task(self._cleanup_loop()) - self.logger.info("Background cleanup worker started") - - async def stop_cleanup_worker(self): - """Stop the background cleanup worker""" - if self._cleanup_task is not None: - _ = self._cleanup_task.cancel() - try: - await self._cleanup_task - except asyncio.CancelledError: - pass - self._cleanup_task = None - self._release_lock() - self.logger.info("Background cleanup worker stopped") - - async def _cleanup_loop(self): - """Main cleanup loop that runs every 10 minutes""" - while True: - self._touch_lock() - try: - logging.info("Cleaning up expired pastes") - # Try to acquire lock (only one worker can run cleanup) - await self._cleanup_expired_pastes() - - # Wait 5 minutes before next run - await asyncio.sleep(300) - except Exception as exc: - self.logger.error("Error in cleanup loop: %s", exc) - await asyncio.sleep(60) # Retry after 1 minute on error - - def _touch_lock(self): - self._lock_file.touch() - - def _acquire_lock(self) -> bool: - """Try to acquire cleanup lock""" - try: - if self._lock_file.exists(): - # Check if lock is stale (older than 15 minutes) - lock_time = self._lock_file.stat().st_mtime - if datetime.now().timestamp() - lock_time < 900: # 15 minutes - return False # Lock still valid - - # Create or update lock file - self._touch_lock() - logging.info("Cleanup lock acquired") - return True - except Exception as exc: - self.logger.error("Failed to acquire cleanup lock: %s", exc) - return False - - def _release_lock(self): - """Release cleanup lock""" - try: - if self._lock_file.exists(): - self._lock_file.unlink() - except Exception as exc: - self.logger.error("Failed to release cleanup lock: %s", exc) - - async def _cleanup_expired_pastes(self): - """Remove expired pastes and their files""" - from app.api.subroutes.pastes import cache - - try: - async with self.session_maker() as session: - current_time = datetime.now(tz=timezone.utc) - # Get expired paste IDs - stmt = select(PasteEntity.id, PasteEntity.content_path).where( - PasteEntity.expires_at < current_time - ) - result = await session.execute(stmt) - expired_pastes = result.fetchall() - - if not expired_pastes: - return - - error: bool = False - # Delete from database and Files - for paste_id, content_path in expired_pastes: - await cache.delete(paste_id) - - delete_stmt = delete(PasteEntity).where(PasteEntity.id == paste_id) - file_path = Path(self.paste_base_folder_path) / content_path - try: - if file_path.exists(): - file_path.unlink() - await session.execute(delete_stmt) - await session.commit() - except Exception as exc: - error = True - self.logger.error( - "Failed to remove file %s: %s", file_path, exc - ) - if not error: - delete_stmt = delete(PasteEntity).where( - PasteEntity.expires_at < current_time - ) - await session.execute(delete_stmt) - await session.commit() - except Exception as exc: - self.logger.error("Failed to cleanup expired pastes: %s", exc) + def _generate_token(self) -> str: + return uuid.uuid4().hex async def _read_content(self, paste_path: str) -> str | None: try: @@ -246,7 +146,105 @@ async def get_paste_by_id(self, paste_id: UUID4) -> PasteResponse | None: content_language=PasteContentLanguage(result.content_language), created_at=result.created_at, expires_at=result.expires_at, + last_updated_at=result.last_updated_at, + ) + + async def edit_paste( + self, paste_id: UUID4, edit_paste: EditPaste, edit_token: str + ) -> PasteResponse | None: + async with self.session_maker() as session: + stmt = ( + select(PasteEntity) + .where( + PasteEntity.id == paste_id, + PasteEntity.edit_token == edit_token, + or_( + PasteEntity.expires_at > datetime.now(tz=timezone.utc), + PasteEntity.expires_at.is_(None), + ), + ) + .limit(1) + ) + result: PasteEntity | None = ( + await session.execute(stmt) + ).scalar_one_or_none() + if result is None: + return None + + # Update only the fields that are provided (not None) + if ( + edit_paste.title is not None + ): # Using ellipsis as sentinel for "not provided" + result.title = edit_paste.title + if edit_paste.content_language is not None: + result.content_language = edit_paste.content_language.value + if edit_paste.is_expires_at_set(): + result.expires_at = edit_paste.expires_at + + # Handle content update separately + if edit_paste.content is not None: + new_content_path = await self._save_content( + str(paste_id), edit_paste.content + ) + if not new_content_path: + return None + result.content_path = new_content_path + result.content_size = len(edit_paste.content) + + result.last_updated_at = datetime.now(tz=timezone.utc) + + await session.commit() + await session.refresh(result) + + # Re-read content if updated + content = ( + edit_paste.content + if edit_paste.content is not None + else await self._read_content( + path.join(self.paste_base_folder_path, result.content_path) + ) + ) + + return PasteResponse( + id=result.id, + title=result.title, + content=content, + content_language=PasteContentLanguage(result.content_language), + expires_at=result.expires_at, + created_at=result.created_at, + last_updated_at=result.last_updated_at, + ) + + async def delete_paste(self, paste_id: UUID4, delete_token: str) -> bool: + async with self.session_maker() as session: + stmt = ( + select(PasteEntity) + .where( + PasteEntity.id == paste_id, + PasteEntity.delete_token == delete_token, + or_( + PasteEntity.expires_at > datetime.now(tz=timezone.utc), + PasteEntity.expires_at.is_(None), + ), + ) + .limit(1) ) + result: PasteEntity | None = ( + await session.execute(stmt) + ).scalar_one_or_none() + if result is None: + return False + + # Remove file + try: + await self._remove_file(result.content_path) + except Exception: + pass # File might already be deleted + + # Delete from database + await session.delete(result) + await session.commit() + return True async def create_paste( self, paste: CreatePaste, user_data: UserMetaData @@ -279,18 +277,23 @@ async def create_paste( creator_ip=str(user_data.ip), creator_user_agent=user_data.user_agent, content_size=len(paste.content), + edit_token=self._generate_token(), + delete_token=self._generate_token(), ) session.add(entity) await session.commit() await session.refresh(entity) - return PasteResponse( + return CreatePasteResponse( id=entity.id, title=entity.title, content=paste.content, content_language=PasteContentLanguage(entity.content_language), created_at=entity.created_at, + last_updated_at=entity.last_updated_at, expires_at=entity.expires_at, + edit_token=entity.edit_token, + delete_token=entity.delete_token, ) except Exception as exc: self.logger.error("Failed to create paste: %s", exc) diff --git a/backend/main.py b/backend/main.py index fc0bac9..e6458b0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -16,6 +16,7 @@ from app.config import config from app.containers import Container from app.ratelimit import limiter +from app.services.cleanup_service import CleanupService from app.services.paste_service import PasteService @@ -33,14 +34,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # Initialize resources (e.g., DB engine) and wire dependencies await container.init_resources() # pyright: ignore[reportGeneralTypeIssues] container.wire() - paste_service: PasteService = ( - await container.paste_service() # pyright: ignore[reportGeneralTypeIssues] + cleanup_service: CleanupService = ( + await container.cleanup_service() # pyright: ignore[reportGeneralTypeIssues] ) # or however you resolve it - paste_service.start_cleanup_worker() + cleanup_service.start_cleanup_worker() try: yield finally: - await paste_service.stop_cleanup_worker() + await cleanup_service.stop_cleanup_worker() await container.shutdown_resources() # pyright: ignore[reportGeneralTypeIssues]