diff --git a/liberate.db b/liberate.db deleted file mode 100644 index df453ba..0000000 Binary files a/liberate.db and /dev/null differ diff --git a/pdm.lock b/pdm.lock index 79be9fd..38cb569 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,18 @@ groups = ["default", "hooks", "style", "tests", "typing"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:0c82da0936c746572b4b8899dcf16f2f76a285aea1296c5e4097919eeaad36bb" +content_hash = "sha256:1bcc74c1878e0617b03e13969e187d0fee770d59e5f68872c054654cb43954b6" + +[[package]] +name = "aiosqlite" +version = "0.19.0" +requires_python = ">=3.7" +summary = "asyncio bridge to the standard sqlite3 module" +groups = ["default"] +files = [ + {file = "aiosqlite-0.19.0-py3-none-any.whl", hash = "sha256:edba222e03453e094a3ce605db1b970c4b3376264e56f32e2a4959f948d66a96"}, + {file = "aiosqlite-0.19.0.tar.gz", hash = "sha256:95ee77b91c8d2808bd08a59fbebf66270e9090c3d92ffbf260dc0db0b979577d"}, +] [[package]] name = "annotated-types" @@ -779,6 +790,20 @@ files = [ {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, ] +[[package]] +name = "pytest-mock" +version = "3.6.1" +requires_python = ">=3.6" +summary = "Thin-wrapper around the mock package for easier use with pytest" +groups = ["tests"] +dependencies = [ + "pytest>=5.0", +] +files = [ + {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, + {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, +] + [[package]] name = "python-dotenv" version = "1.0.0" diff --git a/pyproject.toml b/pyproject.toml index ae21840..a887d1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,13 +16,14 @@ dependencies = [ "SQLAlchemy>=2.0.25", "psycopg2-binary>=2.9.9", "validators>=0.22.0", + "aiosqlite>=0.19.0", ] requires-python = ">=3.11.6" readme = "README.md" license = {text = "MIT"} [tool.pdm.scripts] -start = "uvicorn src.link_liberate.main:app --host 0.0.0.0 --port 8080 --workers 1 --reload" +start = "uvicorn src.link_liberate.main:app --host 0.0.0.0 --port 8080 --workers 4 --reload" dev = "uvicorn src.link_liberate.main:app --host 0.0.0.0 --port 8080 --reload" test = "pytest" mypy = "mypy src/link_liberate" diff --git a/requirements.txt b/requirements.txt index 0f7b614..99cd7f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,3 +59,8 @@ virtualenv==20.25.0 watchfiles==0.21.0 websockets==12.0 wrapt==1.16.0 + +SQLAlchemy~=2.0.25 +validators~=0.22.0 + +aiosqlite~=0.19.0 diff --git a/src/link_liberate/database.py b/src/link_liberate/database.py index 87dd619..f6db66b 100644 --- a/src/link_liberate/database.py +++ b/src/link_liberate/database.py @@ -2,6 +2,9 @@ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker +import aiosqlite +import asyncio + SQLALCHEMY_DATABASE_URL = "sqlite:///./liberate.db" engine = create_engine( @@ -19,3 +22,16 @@ def get_db(): yield db finally: db.close() + + +async def create_async_session(): + async with aiosqlite.connect(SQLALCHEMY_DATABASE_URL) as db: + yield db + + +async def expire_uuid(uuid): + print("Expiring uuid:", uuid) + await asyncio.sleep(60) # Default expiration time is 60 mins + async with create_async_session() as db: + await db.execute("DELETE FROM liberatedlinks WHERE uuid = ?", (uuid,)) + await db.commit() diff --git a/src/link_liberate/main.py b/src/link_liberate/main.py index 07c0aa9..0b03628 100644 --- a/src/link_liberate/main.py +++ b/src/link_liberate/main.py @@ -8,16 +8,20 @@ ) from fastapi.middleware.cors import CORSMiddleware from fastapi.templating import Jinja2Templates + from typing import Annotated + from slowapi.errors import RateLimitExceeded from slowapi import Limiter, _rate_limit_exceeded_handler + from slowapi.util import get_remote_address from sqlalchemy.orm import Session +from asyncio import create_task from starlette.templating import _TemplateResponse from .utils import generate_uuid, make_proper_url, check_link from .models import Base, LiberatedLink -from .database import engine, get_db +from .database import engine, get_db, expire_uuid limiter = Limiter(key_func=get_remote_address) app: FastAPI = FastAPI(title="link-liberate") @@ -38,8 +42,7 @@ allow_headers=["*"], ) -templates: Jinja2Templates = Jinja2Templates( - directory=str(Path(BASE_DIR, "templates"))) +templates: Jinja2Templates = Jinja2Templates(directory=str(Path(BASE_DIR, "templates"))) @app.get("/", response_class=HTMLResponse) @@ -74,6 +77,7 @@ async def web_post( db.add(new_liberated_link) db.commit() db.refresh(new_liberated_link) + await create_task(expire_uuid(uuid)) context = {"link": link, "short": f"{BASE_URL}/{uuid}"} except Exception as e: raise HTTPException( @@ -91,8 +95,7 @@ async def get_link( ) -> RedirectResponse: path: str = f"data/{uuid}" try: - link = db.query(LiberatedLink).filter( - LiberatedLink.uuid == uuid).first() + link = db.query(LiberatedLink).filter(LiberatedLink.uuid == uuid).first() return RedirectResponse( url=link.link, status_code=status.HTTP_301_MOVED_PERMANENTLY ) diff --git a/src/link_liberate/templates/base.html b/src/link_liberate/templates/base.html index dfac793..1fcc2cc 100644 --- a/src/link_liberate/templates/base.html +++ b/src/link_liberate/templates/base.html @@ -23,7 +23,7 @@ } a { - text-decoration: none !important; + text-decoration: none !important; } h1 { @@ -57,6 +57,48 @@ .submit-button:hover { background-color: #45a049; } + + .tooltip { + position: relative; + display: inline-block; + border-bottom: 1px dotted black; + } + + .tooltip .tooltiptext { + visibility: hidden; + width: 120px; + background-color: #555; + color: #fff; + text-align: center; + padding: 5px 0; + border-radius: 6px; + + position: absolute; + z-index: 1; + bottom: 125%; + left: 50%; + margin-left: -60px; + + opacity: 0; + transition: opacity 0.3s; + } + + .tooltip .tooltiptext::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #555 transparent transparent transparent; + } + + .tooltip:hover .tooltiptext { + visibility: visible; + opacity: 1; + } + .copy-button { background-color: #4CAF50; color: #fff; @@ -85,4 +127,4 @@ {% endblock %} - + \ No newline at end of file diff --git a/src/link_liberate/templates/liberate.html b/src/link_liberate/templates/liberate.html index 4f53fcc..ed4f0c1 100644 --- a/src/link_liberate/templates/liberate.html +++ b/src/link_liberate/templates/liberate.html @@ -20,8 +20,33 @@
+
+ + Enable link expiration +
🛈 + The shortened link will stop working after some time. +
+ +
+ + {% endblock %} diff --git a/tests/test_main.py b/tests/test_main.py index 6e0e6f9..a3bfe3f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -21,16 +21,15 @@ def test_web(): def test_web_post(mocker): # Patch the get_db function to return the db mock - mocker.patch('src.link_liberate.database.Session_Local', return_value=Mock()) + mocker.patch("src.link_liberate.database.Session_Local", return_value=Mock()) client_mock = TestClient(app) response = client_mock.post("/liberate", data={"content": "valid_content"}) assert response.status_code == 200 - def test_get_link(mocker): # Patch the get_db function to return the db mock - mocker.patch('src.link_liberate.database.Session_Local', return_value=Mock()) + mocker.patch("src.link_liberate.database.Session_Local", return_value=Mock()) client_mock = TestClient(app) response = client_mock.get("/valid_uuid", follow_redirects=False) assert response.status_code == 301