From 2bc74b1ee15b805fcbfc1b9749e471e5fcd61ff7 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 28 Jan 2024 15:22:18 +0530 Subject: [PATCH 1/3] chore: minor dependency stuff --- pdm.lock | 16 +++++++++++++++- requirements.txt | 3 +++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/pdm.lock b/pdm.lock index 79be9fd..99b9d3e 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "hooks", "style", "tests", "typing"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:0c82da0936c746572b4b8899dcf16f2f76a285aea1296c5e4097919eeaad36bb" +content_hash = "sha256:f00869933960b9690aa0bd8bc3f9dbcdd356591acf3a21be2298597a80db06ff" [[package]] name = "annotated-types" @@ -779,6 +779,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/requirements.txt b/requirements.txt index 0f7b614..e297652 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,3 +59,6 @@ virtualenv==20.25.0 watchfiles==0.21.0 websockets==12.0 wrapt==1.16.0 + +SQLAlchemy~=2.0.25 +validators~=0.22.0 From 91825d0f916924c0a9847d6b3e77cb33c6ce0a4d Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 28 Jan 2024 16:49:57 +0530 Subject: [PATCH 2/3] chore: minor refactor --- src/link_liberate/main.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/link_liberate/main.py b/src/link_liberate/main.py index 07c0aa9..bb74f87 100644 --- a/src/link_liberate/main.py +++ b/src/link_liberate/main.py @@ -8,11 +8,15 @@ ) 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 starlette.templating import _TemplateResponse from .utils import generate_uuid, make_proper_url, check_link @@ -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) @@ -91,8 +94,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 ) From 0e4e7d8c5abec3537fe2871180cc6c2efcddc345 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 28 Jan 2024 17:57:30 +0530 Subject: [PATCH 3/3] URL Expiration --- liberate.db | Bin 16384 -> 0 bytes pdm.lock | 13 ++++- pyproject.toml | 3 +- requirements.txt | 2 + src/link_liberate/database.py | 16 +++++++ src/link_liberate/main.py | 5 +- src/link_liberate/templates/base.html | 56 ++++++++++++++++++---- src/link_liberate/templates/liberate.html | 25 ++++++++++ tests/test_main.py | 5 +- 9 files changed, 110 insertions(+), 15 deletions(-) delete mode 100644 liberate.db diff --git a/liberate.db b/liberate.db deleted file mode 100644 index df453ba343c968624b2764af81213d66c3616cc0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI$O-sWt7zgmQvCbAW-o{SL(Sl6JK$u>vT9{f}tyAEC4y%0KcPgsH7iUJ7&5P$##AOHafKmY;|fB*y_@GF4^O{I&vbfx&s zn%DRHmB2so+SSOb1%C6QQ|WenQ5*d(u_KI6~O)fAO2Kq83GW100bZa0SG_<0uX=z1RyXb@YDVO71A`Qw*UYD diff --git a/pdm.lock b/pdm.lock index 99b9d3e..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:f00869933960b9690aa0bd8bc3f9dbcdd356591acf3a21be2298597a80db06ff" +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" 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 e297652..99cd7f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -62,3 +62,5 @@ 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 bb74f87..0b03628 100644 --- a/src/link_liberate/main.py +++ b/src/link_liberate/main.py @@ -16,12 +16,12 @@ 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") @@ -77,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( diff --git a/src/link_liberate/templates/base.html b/src/link_liberate/templates/base.html index 0a8f0cf..3ec7ff9 100644 --- a/src/link_liberate/templates/base.html +++ b/src/link_liberate/templates/base.html @@ -11,7 +11,7 @@ margin: 0; padding: 0; } - + .container { max-width: 600px; align-content: center; @@ -21,20 +21,20 @@ border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); } - + a { text-decoration: none !important; } - + h1 { text-align: center; color: #333; } - + .form-container { text-align: center; } - + .link-input { width: 100%; padding: 10px; @@ -43,7 +43,7 @@ border: 1px solid #ccc; border-radius: 4px; } - + .submit-button { background-color: #4CAF50; color: #fff; @@ -53,11 +53,51 @@ cursor: pointer; font-size: 16px; } - + .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; + } {% block title %} 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 @@ <div class="form-container"> <form method="post" action="/liberate"> <input type="text" name="content" class="link-input" placeholder="Enter your link..."> + <div class="expiration-options"> + <input type="checkbox" id="enable-expiration" name="enable-expiration" checked> + Enable link expiration + <div class="tooltip">🛈 + <span class="tooltiptext">The shortened link will stop working after some time.</span> + </div> + <select name="expiration-time"> + <option value="30">30 minutes</option> + <option value="60">60 minutes</option> + <option value="90">90 minutes</option> + </select> + </div> <button type="submit" class="submit-button">Shorten</button> </form> </div> </div> + +<script> + const tooltipIcon = document.querySelector('.tooltip-icon'); + const hiddenTooltip = document.querySelector('.hidden-tooltip'); + + tooltipIcon.addEventListener('mouseenter', () => { + hiddenTooltip.classList.remove('hidden'); + }); + + tooltipIcon.addEventListener('mouseleave', () => { + hiddenTooltip.classList.add('hidden'); + }); +</script> {% 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