├── .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 |
--------------------------------------------------------------------------------