├── .env.example ├── .gitignore ├── README.md ├── alembic.ini ├── app ├── __init__.py ├── db │ ├── __init__.py │ ├── crud.py │ ├── exceptions │ │ ├── __init__.py │ │ └── decorators.py │ ├── migrations │ │ ├── README │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ ├── 2e29fee4851b_mixin.py │ │ │ ├── 8546557970c4_first.py │ │ │ └── e41718829d62_status_to_file.py │ └── models.py ├── handlers │ ├── __init__.py │ ├── exceptions.py │ └── models.py ├── schemas │ ├── __init__.py │ └── base.py └── v1 │ ├── __init__.py │ ├── binding.py │ └── files │ ├── __init__.py │ ├── crud.py │ ├── dependencies.py │ ├── handlers.py │ ├── misc.py │ ├── schemas.py │ ├── services.py │ └── utils.py ├── config.py ├── main.py ├── misc.py └── requirements.txt /.env.example: -------------------------------------------------------------------------------- 1 | DB_USERNAME=... 2 | DB_PASSWORD=... 3 | DB_PORT=... 4 | DB_HOST=... 5 | DB_BASENAME=... 6 | HOSTING_URL=http://127.0.0.1:8000 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI file hosting 2 | 3 | ### You can see how the requests look before deploying this application to yourself. 4 |
5 | 6 | File creation 7 | 8 |
9 | 10 |
11 | 12 |
13 | 14 | Get details for one file 15 | 16 |
17 | 18 |
19 | 20 |
21 | 22 | Get details for all files 23 | 24 |
25 | 26 |
27 | 28 |
29 | 30 | Download file 31 | 32 |
33 | 34 |
35 | 36 | 37 | > TODO: Deleting files from the directory after the expiration of time (each file lives for some limited amount of time). 38 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = app/db/migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to app/db/migrations/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" below. 39 | # version_locations = %(here)s/bar:%(here)s/bat:app/db/migrations/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 43 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 44 | # Valid values for version_path_separator are: 45 | # 46 | # version_path_separator = : 47 | # version_path_separator = ; 48 | # version_path_separator = space 49 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 50 | 51 | # the output encoding used when revision files 52 | # are written from script.py.mako 53 | # output_encoding = utf-8 54 | 55 | sqlalchemy.url = driver://user:pass@localhost/dbname 56 | 57 | 58 | [post_write_hooks] 59 | # post_write_hooks defines scripts or Python functions that are run 60 | # on newly generated revision scripts. See the documentation for further 61 | # detail and examples 62 | 63 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 64 | # hooks = black 65 | # black.type = console_scripts 66 | # black.entrypoint = black 67 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 68 | 69 | # Logging configuration 70 | [loggers] 71 | keys = root,sqlalchemy,alembic 72 | 73 | [handlers] 74 | keys = console 75 | 76 | [formatters] 77 | keys = generic 78 | 79 | [logger_root] 80 | level = WARN 81 | handlers = console 82 | qualname = 83 | 84 | [logger_sqlalchemy] 85 | level = WARN 86 | handlers = 87 | qualname = sqlalchemy.engine 88 | 89 | [logger_alembic] 90 | level = INFO 91 | handlers = 92 | qualname = alembic 93 | 94 | [handler_console] 95 | class = StreamHandler 96 | args = (sys.stderr,) 97 | level = NOTSET 98 | formatter = generic 99 | 100 | [formatter_generic] 101 | format = %(levelname)-5.5s [%(name)s] %(message)s 102 | datefmt = %H:%M:%S 103 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotX12/fastapi-file-hosting/8b21b6e515b7f30943311a05dad41eead1a6f650/app/__init__.py -------------------------------------------------------------------------------- /app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotX12/fastapi-file-hosting/8b21b6e515b7f30943311a05dad41eead1a6f650/app/db/__init__.py -------------------------------------------------------------------------------- /app/db/crud.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from contextlib import asynccontextmanager 3 | from typing import ( 4 | TypeVar, 5 | ClassVar, 6 | Type, 7 | Any, 8 | Optional, 9 | cast, 10 | List, 11 | AsyncContextManager, Union 12 | ) 13 | 14 | from sqlalchemy import select, update, exists, delete, func, lambda_stmt 15 | from sqlalchemy.ext.asyncio import AsyncSessionTransaction, AsyncSession 16 | from sqlalchemy.orm import sessionmaker 17 | from sqlalchemy.sql import Executable 18 | 19 | Model = TypeVar("Model") 20 | TransactionContext = AsyncContextManager[AsyncSessionTransaction] 21 | 22 | 23 | class BaseCRUD(ABC): 24 | 25 | def __init__( 26 | self, 27 | db_session: Union[sessionmaker, AsyncSession], 28 | model: ClassVar[Type[Model]] 29 | ): 30 | self.model = model 31 | 32 | if isinstance(db_session, sessionmaker): 33 | self.session: AsyncSession = cast(AsyncSession, db_session()) 34 | else: 35 | self.session = db_session 36 | 37 | @asynccontextmanager 38 | async def transaction(self) -> TransactionContext: 39 | async with self.session as transaction: 40 | yield transaction 41 | 42 | async def insert(self, **kwargs: Any) -> Model: 43 | add_model = self._convert_to_model(**kwargs) 44 | self.session.add(add_model) 45 | await self.session.commit() 46 | return add_model 47 | 48 | async def get_one(self, *args) -> Model: 49 | async with self.session as transaction: 50 | stmt = select(self.model).where(*args) 51 | result = await self.session.execute(stmt) 52 | return result.scalar_one() 53 | 54 | async def get_many(self, *args: Any) -> Model: 55 | query_model = self.model 56 | stmt = lambda_stmt(lambda: select(query_model)) 57 | stmt += lambda s: s.where(*args) 58 | query_stmt = cast(Executable, stmt) 59 | 60 | result = await self.session.execute(query_stmt) 61 | return result.scalars().all() 62 | 63 | async def update(self, *args: Any, **kwargs: Any) -> Model: 64 | stmt = ( 65 | update(self.model) 66 | .where(*args) 67 | .values(**kwargs) 68 | .returning(self.model) 69 | ) 70 | 71 | stmt = ( 72 | select(self.model) 73 | .from_statement(stmt) 74 | .execution_options(synchronize_session="fetch") 75 | ) 76 | 77 | result = await self.session.execute(stmt) 78 | await self.session.commit() 79 | return result.scalar_one() 80 | 81 | async def exists(self, *args: Any) -> Optional[bool]: 82 | """Check is row exists in database""" 83 | stmt = exists(select(self.model).where(*args)).select() 84 | result_stmt = await self.session.execute(stmt) 85 | result = result_stmt.scalar() 86 | return cast(Optional[bool], result) 87 | 88 | async def exists_get(self, *args: Any) -> List[Model]: 89 | """Check is row exists in database. If it does, returns the row""" 90 | stmt = select(self.model).where(*args) 91 | result_stmt = await self.session.execute(stmt) 92 | result = result_stmt.scalars().all() 93 | return result 94 | 95 | async def delete(self, *args: Any) -> List[Model]: 96 | stmt = delete(self.model).where(*args).returning("*") 97 | result = await self.session.execute(stmt) 98 | await self.session.commit() 99 | result_all = result.scalars().all() 100 | return result_all 101 | 102 | async def soft_delete(self, *args: Any) -> List[Model]: 103 | return await self.update(*args, status_id=0) 104 | 105 | async def count(self) -> int: 106 | stmt = select(func.count()).select_from(select(self.model).subquery()) 107 | result = await self.session.execute(stmt) 108 | count = result.scalar_one() 109 | return cast(int, count) 110 | 111 | def _convert_to_model(self, **kwargs) -> Model: 112 | return self.model(**kwargs) # type: ignore 113 | -------------------------------------------------------------------------------- /app/db/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotX12/fastapi-file-hosting/8b21b6e515b7f30943311a05dad41eead1a6f650/app/db/exceptions/__init__.py -------------------------------------------------------------------------------- /app/db/exceptions/decorators.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from sqlalchemy.exc import NoResultFound 4 | 5 | from app.handlers.models import ExceptionSQL 6 | 7 | 8 | def handle_not_found_error(_: Optional[NoResultFound] = None, __: Optional[str] = None): 9 | raise ExceptionSQL( 10 | detail="Запись, которую вы ищите или удаляете, отсутствует в базе данных" 11 | ) 12 | 13 | 14 | def orm_error_handler(func): 15 | async def decorator(*args, **kwargs): 16 | try: 17 | return await func(*args, **kwargs) 18 | 19 | except NoResultFound: 20 | handle_not_found_error() 21 | 22 | return decorator 23 | -------------------------------------------------------------------------------- /app/db/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /app/db/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from sqlalchemy import engine_from_config 5 | from sqlalchemy import pool 6 | from sqlalchemy.ext.asyncio import AsyncEngine 7 | 8 | from alembic import context 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | from config import settings_app 13 | from app.db.models import Base 14 | 15 | config = context.config 16 | 17 | # Interpret the config file for Python logging. 18 | # This line sets up loggers basically. 19 | fileConfig(config.config_file_name) 20 | config.set_main_option('sqlalchemy.url', settings_app.dsn) 21 | # add your model's MetaData object here 22 | # for 'autogenerate' support 23 | # from myapp import mymodel 24 | # target_metadata = mymodel.Base.metadata 25 | target_metadata = Base.metadata 26 | 27 | # other values from the config, defined by the needs of env.py, 28 | # can be acquired: 29 | # my_important_option = config.get_main_option("my_important_option") 30 | # ... etc. 31 | 32 | 33 | def run_migrations_offline(): 34 | """Run migrations in 'offline' mode. 35 | 36 | This configures the context with just a URL 37 | and not an Engine, though an Engine is acceptable 38 | here as well. By skipping the Engine creation 39 | we don't even need a DBAPI to be available. 40 | 41 | Calls to context.execute() here emit the given string to the 42 | script output. 43 | 44 | """ 45 | url = config.get_main_option("sqlalchemy.url") 46 | context.configure( 47 | url=url, 48 | target_metadata=target_metadata, 49 | literal_binds=True, 50 | dialect_opts={"paramstyle": "named"}, 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def do_run_migrations(connection): 58 | context.configure(connection=connection, target_metadata=target_metadata) 59 | 60 | with context.begin_transaction(): 61 | context.run_migrations() 62 | 63 | 64 | async def run_migrations_online(): 65 | """Run migrations in 'online' mode. 66 | 67 | In this scenario we need to create an Engine 68 | and associate a connection with the context. 69 | 70 | """ 71 | connectable = AsyncEngine( 72 | engine_from_config( 73 | config.get_section(config.config_ini_section), 74 | prefix="sqlalchemy.", 75 | poolclass=pool.NullPool, 76 | future=True, 77 | ) 78 | ) 79 | 80 | async with connectable.connect() as connection: 81 | await connection.run_sync(do_run_migrations) 82 | 83 | await connectable.dispose() 84 | 85 | 86 | if context.is_offline_mode(): 87 | run_migrations_offline() 88 | else: 89 | asyncio.run(run_migrations_online()) 90 | -------------------------------------------------------------------------------- /app/db/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /app/db/migrations/versions/2e29fee4851b_mixin.py: -------------------------------------------------------------------------------- 1 | """mixin 2 | 3 | Revision ID: 2e29fee4851b 4 | Revises: 8546557970c4 5 | Create Date: 2022-02-04 04:55:48.190362 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '2e29fee4851b' 14 | down_revision = '8546557970c4' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('files', sa.Column('created_at', sa.DateTime(), nullable=True)) 22 | op.add_column('files', sa.Column('updated_at', sa.DateTime(), nullable=True)) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_column('files', 'updated_at') 29 | op.drop_column('files', 'created_at') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /app/db/migrations/versions/8546557970c4_first.py: -------------------------------------------------------------------------------- 1 | """first 2 | 3 | Revision ID: 8546557970c4 4 | Revises: 5 | Create Date: 2022-02-04 04:55:04.352326 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '8546557970c4' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('files', 22 | sa.Column('uuid', postgresql.UUID(as_uuid=True), nullable=False), 23 | sa.Column('original_name', sa.String(length=255), nullable=True), 24 | sa.Column('security_name', sa.String(length=255), nullable=True), 25 | sa.Column('size_bytes', sa.Numeric(), nullable=True), 26 | sa.Column('mime_type', sa.String(length=255), nullable=True), 27 | sa.Column('deleted_at', sa.DateTime(), nullable=True), 28 | sa.PrimaryKeyConstraint('uuid') 29 | ) 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_table('files') 36 | # ### end Alembic commands ### 37 | -------------------------------------------------------------------------------- /app/db/migrations/versions/e41718829d62_status_to_file.py: -------------------------------------------------------------------------------- 1 | """status to file 2 | 3 | Revision ID: e41718829d62 4 | Revises: 2e29fee4851b 5 | Create Date: 2022-02-04 06:23:35.950211 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'e41718829d62' 14 | down_revision = '2e29fee4851b' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('files', sa.Column('status', sa.Boolean(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('files', 'status') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /app/db/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, String, Numeric, DateTime, Boolean 2 | from sqlalchemy.dialects.postgresql import UUID 3 | from sqlalchemy.sql.functions import current_timestamp 4 | 5 | from misc import Base 6 | 7 | 8 | class TimestampMixin(object): 9 | created_at = Column(DateTime, default=current_timestamp()) 10 | updated_at = Column(DateTime, onupdate=current_timestamp()) 11 | 12 | 13 | class FileModel(Base, TimestampMixin): 14 | __tablename__ = "files" 15 | 16 | uuid = Column(UUID(as_uuid=True), primary_key=True) 17 | original_name = Column(String(255)) 18 | security_name = Column(String(255)) 19 | size_bytes = Column(Numeric) 20 | mime_type = Column(String(255)) 21 | deleted_at = Column(DateTime) 22 | status = Column(Boolean, default=1) 23 | -------------------------------------------------------------------------------- /app/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotX12/fastapi-file-hosting/8b21b6e515b7f30943311a05dad41eead1a6f650/app/handlers/__init__.py -------------------------------------------------------------------------------- /app/handlers/exceptions.py: -------------------------------------------------------------------------------- 1 | from starlette import status 2 | from starlette.requests import Request 3 | from starlette.responses import JSONResponse 4 | 5 | from app.handlers.models import ExceptionSQL 6 | 7 | 8 | def sql_exception_handler(_: Request, exc: ExceptionSQL): 9 | return JSONResponse( 10 | status_code=status.HTTP_400_BAD_REQUEST, 11 | content={ 12 | "detail": exc.detail, 13 | "sql_detail": exc.sql_detail 14 | } 15 | ) 16 | -------------------------------------------------------------------------------- /app/handlers/models.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class ExceptionSQL(Exception): 5 | def __init__(self, detail: str, sql_detail: Optional[str] = None): 6 | self.detail = detail 7 | self.sql_detail = sql_detail 8 | -------------------------------------------------------------------------------- /app/schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotX12/fastapi-file-hosting/8b21b6e515b7f30943311a05dad41eead1a6f650/app/schemas/__init__.py -------------------------------------------------------------------------------- /app/schemas/base.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class BaseModelORM(BaseModel): 5 | class Config: 6 | orm_mode = True 7 | allow_population_by_field_name = True 8 | -------------------------------------------------------------------------------- /app/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotX12/fastapi-file-hosting/8b21b6e515b7f30943311a05dad41eead1a6f650/app/v1/__init__.py -------------------------------------------------------------------------------- /app/v1/binding.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.v1.files.handlers import files_router 4 | 5 | own_router_v1 = APIRouter() 6 | own_router_v1.include_router(files_router, tags=['Files']) 7 | -------------------------------------------------------------------------------- /app/v1/files/__init__.py: -------------------------------------------------------------------------------- 1 | from .dependencies import FileDependencyMarker 2 | from .handlers import files_router 3 | from .crud import FileRepository 4 | -------------------------------------------------------------------------------- /app/v1/files/crud.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List 3 | from uuid import UUID 4 | 5 | from fastapi import UploadFile 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | 8 | from app.db.crud import BaseCRUD 9 | from app.db.exceptions.decorators import orm_error_handler 10 | from app.db.models import FileModel 11 | from app.v1.files.utils import UploadFileHelper 12 | 13 | 14 | class FileRepository: 15 | def __init__(self, db_session: AsyncSession): 16 | self.db_session = db_session 17 | self.model = FileModel 18 | 19 | self.base = BaseCRUD(db_session=db_session, model=self.model) 20 | 21 | async def create_upload_file(self, upload_file: UploadFile, expire_time: datetime) -> FileModel: 22 | ufh = UploadFileHelper(upload_file) 23 | await ufh.save_file() 24 | 25 | async with self.base.transaction(): 26 | return await self.base.insert( 27 | uuid=ufh.uuid, 28 | original_name=ufh.original_filename, 29 | security_name=ufh.security_filename, 30 | size_bytes=ufh.size, 31 | mime_type=ufh.content_type, 32 | deleted_at=expire_time, 33 | ) 34 | 35 | @orm_error_handler 36 | async def get_upload_file(self, uuid: UUID) -> FileModel: 37 | async with self.base.transaction(): 38 | return await self.base.get_one(self.model.uuid == uuid) 39 | 40 | @orm_error_handler 41 | async def get_upload_files(self) -> List[FileModel]: 42 | async with self.base.transaction(): 43 | return await self.base.get_many() 44 | -------------------------------------------------------------------------------- /app/v1/files/dependencies.py: -------------------------------------------------------------------------------- 1 | class FileDependencyMarker: 2 | pass 3 | -------------------------------------------------------------------------------- /app/v1/files/handlers.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from uuid import UUID 3 | 4 | from fastapi import APIRouter, Depends 5 | from pydantic import Field 6 | from starlette.responses import FileResponse 7 | 8 | from app.v1.files.crud import FileRepository 9 | from app.v1.files.dependencies import FileDependencyMarker 10 | from app.v1.files.schemas import GetFileModel, PostFormModel 11 | from app.v1.files.services import FileService 12 | 13 | files_router = APIRouter() 14 | 15 | 16 | @files_router.post("/files", response_model=GetFileModel) 17 | async def create_upload_file( 18 | data: PostFormModel = Depends(), 19 | db: FileRepository = Depends(FileDependencyMarker), 20 | file_service: FileService = Depends(), 21 | ): 22 | file_expire_time = file_service.generate_delete_file_date(hours=data.ttl) 23 | upload_file = await db.create_upload_file(upload_file=data.file, expire_time=file_expire_time) 24 | created_file = await db.get_upload_file(uuid=upload_file.uuid) 25 | 26 | return file_service.generate_response_model( 27 | uuid=created_file.uuid, 28 | original_name=created_file.original_name, 29 | size_bytes=created_file.size_bytes, 30 | deleted_at=created_file.deleted_at, 31 | status=created_file.status, 32 | created_at=created_file.created_at, 33 | mime_type=created_file.mime_type, 34 | ) 35 | 36 | 37 | @files_router.get("/files/{uuid}/info", response_model=GetFileModel) 38 | async def get_file_info( 39 | uuid: UUID, 40 | db: FileRepository = Depends(FileDependencyMarker), 41 | file_service: FileService = Depends(), 42 | ): 43 | result = await db.get_upload_file(uuid=uuid) 44 | return file_service.generate_response_model( 45 | uuid=result.uuid, 46 | original_name=result.original_name, 47 | size_bytes=result.size_bytes, 48 | deleted_at=result.deleted_at, 49 | status=result.status, 50 | created_at=result.created_at, 51 | mime_type=result.mime_type, 52 | ) 53 | 54 | 55 | @files_router.get("/files", response_model=List[GetFileModel]) 56 | async def get_files_info( 57 | db: FileRepository = Depends(FileDependencyMarker), 58 | file_service: FileService = Depends(), 59 | ): 60 | result = await db.get_upload_files() 61 | return [ 62 | file_service.generate_response_model( 63 | uuid=element.uuid, 64 | original_name=element.original_name, 65 | size_bytes=element.size_bytes, 66 | deleted_at=element.deleted_at, 67 | status=element.status, 68 | created_at=element.created_at, 69 | mime_type=element.mime_type, 70 | ) for element in result 71 | ] 72 | 73 | 74 | @files_router.get("/files/{uuid}") 75 | async def get_file( 76 | uuid: UUID, 77 | db: FileRepository = Depends(FileDependencyMarker), 78 | file_service: FileService = Depends(), 79 | ): 80 | file_db = await db.get_upload_file(uuid=uuid) 81 | file_path = file_service.file_path_from_security_name(file_db.security_name) 82 | return FileResponse(path=file_path, filename=file_db.original_name) 83 | -------------------------------------------------------------------------------- /app/v1/files/misc.py: -------------------------------------------------------------------------------- 1 | UPLOAD_FILE_TTL_DESCRIPTION = ( 2 | "Time To Live in hours (1-168). " 3 | "If this value is provided, the file will be force deleted after ttl number of hours." 4 | ) 5 | -------------------------------------------------------------------------------- /app/v1/files/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Type, Any, Dict, Optional 3 | from uuid import UUID 4 | 5 | from fastapi import UploadFile, File, Form 6 | from pydantic import validator 7 | 8 | from app.schemas.base import BaseModelORM 9 | from app.v1.files.misc import UPLOAD_FILE_TTL_DESCRIPTION 10 | from app.v1.files.utils import convert_size 11 | from config import settings_app 12 | 13 | 14 | class PostFormModel: 15 | def __init__( 16 | self, 17 | file: UploadFile = File(..., description="File to upload."), 18 | ttl: int = Form(default=1, ge=1, le=180, description=UPLOAD_FILE_TTL_DESCRIPTION) 19 | ): 20 | self.file = file 21 | self.ttl = ttl 22 | 23 | 24 | class URLModel(BaseModelORM): 25 | minimal: str 26 | 27 | @validator("minimal", always=True) 28 | def validate_minimal(cls: Type['URLModel'], v: Any) -> str: 29 | try: 30 | assert UUID(v) 31 | return f"{settings_app.HOSTING_URL}/files/{v}" 32 | except ValueError: 33 | return v 34 | 35 | 36 | class SizeModel(BaseModelORM): 37 | bytes: int 38 | readable: Optional[str] = None 39 | 40 | @validator("readable", always=True) 41 | def validate_readable(cls: Type['SizeModel'], _: Any, values: Dict[str, Any]) -> str: 42 | _bytes = values.get("bytes") 43 | return convert_size(_bytes) 44 | 45 | 46 | class MetaModel(BaseModelORM): 47 | name: str 48 | mimetype: str 49 | size: SizeModel 50 | 51 | 52 | class GetFileModel(BaseModelORM): 53 | uuid: UUID 54 | url: URLModel 55 | meta: MetaModel 56 | created_at: datetime 57 | deleted_at: datetime 58 | available: bool 59 | 60 | -------------------------------------------------------------------------------- /app/v1/files/services.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | from typing import Union 4 | from uuid import UUID 5 | 6 | from app.v1.files.schemas import GetFileModel, MetaModel, URLModel, SizeModel 7 | 8 | 9 | class FileService: 10 | 11 | @staticmethod 12 | def generate_delete_file_date(hours: int): 13 | return datetime.datetime.now() + datetime.timedelta(hours=hours) 14 | 15 | @staticmethod 16 | def file_path_from_security_name(name: str): 17 | return os.path.join(os.getcwd(), 'files', name) 18 | 19 | @staticmethod 20 | def generate_response_model( 21 | uuid: UUID, 22 | original_name: str, 23 | size_bytes: Union[int, float], 24 | mime_type: str, 25 | deleted_at: datetime, 26 | created_at: datetime, 27 | status: bool, 28 | ): 29 | url = URLModel(minimal=str(uuid)) 30 | size = SizeModel(bytes=size_bytes) 31 | meta = MetaModel(name=original_name, mimetype=mime_type, size=size) 32 | 33 | return GetFileModel( 34 | uuid=uuid, 35 | created_at=created_at, 36 | deleted_at=deleted_at, 37 | available=status, 38 | meta=meta, 39 | url=url, 40 | ) 41 | -------------------------------------------------------------------------------- /app/v1/files/utils.py: -------------------------------------------------------------------------------- 1 | import math 2 | import os 3 | from uuid import uuid4 4 | from aiofile import Writer, AIOFile 5 | 6 | from fastapi import UploadFile 7 | 8 | 9 | class UploadFileHelper: 10 | def __init__(self, file: UploadFile): 11 | self.file = file 12 | self._uuid = uuid4() 13 | 14 | @property 15 | def uuid(self): 16 | return self._uuid 17 | 18 | @property 19 | def security_filename(self): 20 | return f'{str(self._uuid)}__{self.file.filename}' 21 | 22 | @property 23 | def original_filename(self): 24 | return self.file.filename 25 | 26 | @property 27 | def content_type(self): 28 | return self.file.content_type 29 | 30 | @property 31 | def size(self): 32 | return len(self.open_file) 33 | 34 | async def _open(self): 35 | self.open_file = await self.file.read() 36 | 37 | async def _close(self): 38 | await self.file.close() 39 | 40 | async def save_file(self): 41 | await self._open() 42 | 43 | path_file = os.path.join(os.getcwd(), 'files', self.security_filename) 44 | async with AIOFile(path_file, 'wb') as buffer: 45 | writer = Writer(buffer) 46 | await writer(self.open_file) 47 | 48 | await self._close() 49 | 50 | 51 | def convert_size(size_bytes: int): 52 | if size_bytes == 0: 53 | return "0 B" 54 | size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") 55 | i = int(math.floor(math.log(size_bytes, 1024))) 56 | p = math.pow(1024, i) 57 | s = round(size_bytes / p, 2) 58 | return f"{s} {size_name[i]}" 59 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings, Field 2 | 3 | 4 | class Settings(BaseSettings): 5 | DB_USERNAME: str = Field(env='DB_USERNAME') 6 | DB_PASSWORD: str = Field(env='DB_PASSWORD') 7 | DB_PORT: int = Field(env='DB_PORT') 8 | DB_HOST: str = Field(env='DB_HOST') 9 | DB_BASENAME: str = Field(env='DB_BASENAME') 10 | 11 | HOSTING_URL: str = Field(env='HOSTING_URL') 12 | 13 | @property 14 | def dsn(self): 15 | return ( 16 | f"postgresql+asyncpg://{self.DB_USERNAME}:{self.DB_PASSWORD}" 17 | f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_BASENAME}" 18 | ) 19 | 20 | class Config: 21 | env_file = '.env' 22 | env_file_encoding = 'utf-8' 23 | 24 | 25 | settings_app = Settings() 26 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from starlette.middleware.cors import CORSMiddleware 3 | from starlette.middleware.trustedhost import TrustedHostMiddleware 4 | 5 | from app.handlers.exceptions import sql_exception_handler 6 | from app.handlers.models import ExceptionSQL 7 | from app.v1.binding import own_router_v1 8 | from app.v1.files import FileRepository, FileDependencyMarker 9 | from misc import async_session 10 | 11 | 12 | def get_application_v1() -> FastAPI: 13 | application = FastAPI( 14 | debug=True, 15 | title='Files Hosting', 16 | version='1.2.15', 17 | ) 18 | 19 | application.add_middleware( 20 | CORSMiddleware, 21 | allow_origins=["*"], 22 | allow_credentials=True, 23 | allow_methods=["*"], 24 | allow_headers=["*"], 25 | ) 26 | 27 | application.add_middleware( 28 | TrustedHostMiddleware, 29 | allowed_hosts=['*'] 30 | ) 31 | application.include_router(own_router_v1) 32 | application.add_exception_handler(ExceptionSQL, sql_exception_handler) 33 | 34 | application.dependency_overrides.update( 35 | { 36 | FileDependencyMarker: lambda: FileRepository(db_session=async_session), 37 | } 38 | ) 39 | 40 | return application 41 | 42 | 43 | app = get_application_v1() 44 | -------------------------------------------------------------------------------- /misc.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession 3 | from sqlalchemy.orm import sessionmaker, declarative_base 4 | from config import settings_app 5 | 6 | 7 | DATABASE_URL = settings_app.dsn 8 | engine = create_async_engine(DATABASE_URL, future=True, echo=False) 9 | async_session = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) 10 | 11 | metadata = sqlalchemy.MetaData() 12 | Base = declarative_base(metadata=metadata) 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiofile==3.7.4 2 | alembic==1.7.6 3 | anyio==3.5.0 4 | asgiref==3.5.0 5 | asyncpg==0.25.0 6 | caio==0.9.3 7 | click==8.0.3 8 | colorama==0.4.4 9 | fastapi==0.73.0 10 | greenlet==1.1.2 11 | h11==0.13.0 12 | idna==3.3 13 | Mako==1.1.6 14 | MarkupSafe==2.0.1 15 | pydantic==1.9.0 16 | python-dotenv==0.19.2 17 | python-multipart==0.0.5 18 | six==1.16.0 19 | sniffio==1.2.0 20 | SQLAlchemy==1.4.31 21 | starlette==0.17.1 22 | typing-extensions==4.0.1 23 | uvicorn==0.17.3 24 | --------------------------------------------------------------------------------