From df363e2297db1c285065a8b24d9e2664a8efebc3 Mon Sep 17 00:00:00 2001 From: sunwoo1524 Date: Mon, 19 Feb 2024 12:21:12 +0900 Subject: [PATCH] v2 --- .dockerignore | 5 ++ .env.example | 4 ++ .gitignore | 9 ++++ Dockerfile | 9 ++++ docker-compose.example.yml | 18 +++++++ frontend/index.html | 33 ++++++++++++ frontend/static/root.css | 25 +++++++++ frontend/static/script.js | 97 +++++++++++++++++++++++++++++++++++ frontend/static/style.css | 90 ++++++++++++++++++++++++++++++++ main.py | 33 ++++++++++++ requirements.txt | 5 ++ src/database.py | 23 +++++++++ src/env.py | 10 ++++ src/models.py | 10 ++++ src/routes/url/url_crud.py | 24 +++++++++ src/routes/url/url_route.py | 53 +++++++++++++++++++ src/routes/url/url_schemas.py | 10 ++++ 17 files changed, 458 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.example.yml create mode 100644 frontend/index.html create mode 100644 frontend/static/root.css create mode 100644 frontend/static/script.js create mode 100644 frontend/static/style.css create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 src/database.py create mode 100644 src/env.py create mode 100644 src/models.py create mode 100644 src/routes/url/url_crud.py create mode 100644 src/routes/url/url_route.py create mode 100644 src/routes/url/url_schemas.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a82702f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +__pycache__/ + +venv/ + +postgres/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e121e4 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +POSTGRES_HOST=db:5432 +POSTGRES_DATABASE=postgres +POSTGRES_USER=postgres +POSTGRES_PASSWORD=changeme! \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85e47f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +venv/ + +__pycache__/ + +.env + +docker-compose.yml + +postgres/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..66e614d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:latest + +WORKDIR /app + +COPY . /app + +RUN pip install --no-cache-dir --upgrade -r requirements.txt + +ENTRYPOINT gunicorn main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..770a2a7 --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,18 @@ +version: "3" + +services: + db: + image: postgres:latest + restart: always + env_file: + - ./.env + volumes: + - ./postgres:/var/lib/postgresql/data + + web: + build: . + restart: always + ports: + - 8000:8000 + depends_on: + - db diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ee2c3ca --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,33 @@ + + + + + + Krll URL Shortener + + + + + +
+
+ + +
+ +
+

Your URL is invalid!

+

+

Copied!

+ +
+
+ + + + + + \ No newline at end of file diff --git a/frontend/static/root.css b/frontend/static/root.css new file mode 100644 index 0000000..6c617a8 --- /dev/null +++ b/frontend/static/root.css @@ -0,0 +1,25 @@ +@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500&display=swap'); + +* { + font-family: 'Poppins', sans-serif; +} + +:root { + color-scheme: light dark; + + --background-color: light-dark(white, rgb(29, 29, 29)); + --input-background-color: light-dark(white, rgb(53, 53, 53)); + + --text-color: light-dark(black, white); + --footer-text-color: light-dark(rgb(90, 90, 90), rgb(173, 173, 173)); + --button-text-color: white; + --link-color: light-dark(#2e2e2e, #7a7a7a); + + --border-color: light-dark(lightgray, gray); + --border-highlight-color: darkgray; +} + +body { + margin: 0; + background-color: var(--background-color); +} \ No newline at end of file diff --git a/frontend/static/script.js b/frontend/static/script.js new file mode 100644 index 0000000..bc5a2b2 --- /dev/null +++ b/frontend/static/script.js @@ -0,0 +1,97 @@ +const url_input = document.getElementById("url"); +const to_shorten_btn = document.getElementById("to-shorten-btn"); +const result_container = document.getElementById("result-container"); +const short_url = document.getElementById("short-url"); +const qr_code = document.getElementById("qr-code"); +const error_message = document.getElementById("error"); +const copy_message = document.getElementById("copied"); + +to_shorten_btn.addEventListener("click", async () => { + const original_url = url_input.value + // check if the url input is empty + if (original_url.length === 0) { + return; + } + + // Easter egg: rick roll + if (rickroll.check(original_url)){ + window.location.replace("https://youtu.be/dQw4w9WgXcQ"); + } + + // shorten the long url + const response = await fetch("/api/urls", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + url: original_url + }) + }) + + // check if there is problems + if (!response.ok) { + if (response.status === 400) { + // the url is invalid + error_message.style.display = "block"; + setTimeout(() => error_message.style.display = "none", 5000); + } else { + // there is some problems in the server! + alert("Sorry, an error has occured in the server..."); + } + + return; + } + + const data = await response.json(); + const result = `https://krll.me/${data.key}` + + // show the short url + short_url.innerText = result; + + // get the qr code of the short url and show it + qr_code.src = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${result}`; + qr_code.alt = result; +}); + +short_url.addEventListener("click", event => { + // copy the short url to clipboard + navigator.clipboard.writeText(event.target.innerText) + .then(() => { + // show a message + copy_message.style.opacity = "1"; + setTimeout(() => copy_message.style.opacity = "0", 3000); + }) + .catch(error => { + alert("An error has occurred...\n" + error); + }); +}); + +const rickroll = { + check: (url)=>{ + try { + const urlObj = new URL(url); + let videoId = ""; + if (urlObj.host.split(".").slice(-2)[0] === "youtube") { + if (urlObj.pathname.split("/")[1] === "watch") { + videoId = urlObj.searchParams.get("v"); + } + else { + return false; + } + } + else if (urlObj.host === "youtu.be") { + videoId = urlObj.pathname.slice(1); + } + else { + return false + } + if (videoId ==="dQw4w9WgXcQ") return true + return false + } + catch { + return false + } + } +} + diff --git a/frontend/static/style.css b/frontend/static/style.css new file mode 100644 index 0000000..37edbd4 --- /dev/null +++ b/frontend/static/style.css @@ -0,0 +1,90 @@ +main { + padding-top: 100px; + display: flex; + flex-direction:column; + align-items: center; + height: 500px; +} + +.input-container { + display: flex; + justify-content: center; + width: 500px; +} + +@media screen and (max-width: 500px) { + .input-container { + width: 95%; + } +} + +.input-container input { + font-size: 20px; + box-sizing: border-box; + padding: 6px; + border: solid 1px var(--border-color); + border-radius: 5px 0 0 5px; + width: 100%; + color: var(--text-color); + background-color: var(--input-background-color); +} + +.input-container input:focus { + outline: none; + border-color: var(--border-highlight-color); +} + +.input-container button { + font-size: 20px; + box-sizing: border-box; + border: none; + border-radius: 0 5px 5px 0; + color: var(--button-text-color); + background-color: #6F59F5; + padding: 0 10px; +} + +.result-container { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 20px; + font-size: 18px; +} + +.result-container p { + margin: 0; +} + +.result-container #error { + display: none; +} + +.result-container #short-url { + cursor: pointer; + margin-top: 10px; +} + +.result-container #copied { + opacity: 0; + margin-top: 10px; + margin-bottom: 20px; + font-size: 15px; +} + +footer { + display: flex; + flex-direction: column; + align-items: center; +} + +footer p { + margin: 3px 0; + font-size: 14px; + color: var(--footer-text-color); +} + +footer p a { + color: var(--link-color); + text-decoration: none; +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..6c4c352 --- /dev/null +++ b/main.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware + +from src.database import engine +from src import models +from src.routes.url import url_route + + +models.Base.metadata.create_all(bind=engine) + +app = FastAPI() + +origins = [ + "*", +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(url_route.routes) + +# frontend v2 +app.mount("/static", StaticFiles(directory="./frontend/static")) +@app.get("/") +def frontend(): + return FileResponse("./frontend/index.html") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2e7d16f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi[all] +gunicorn +sqlalchemy +psycopg2 +python-dotenv \ No newline at end of file diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..4a49445 --- /dev/null +++ b/src/database.py @@ -0,0 +1,23 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from .env import POSTGRES_HOST, POSTGRES_DATABASE, POSTGRES_USER, POSTGRES_PASSWORD + +SQLALCHEMY_DATABASE_URL = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}/{POSTGRES_DATABASE}" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + + try: + yield db + finally: + db.close() diff --git a/src/env.py b/src/env.py new file mode 100644 index 0000000..7242d23 --- /dev/null +++ b/src/env.py @@ -0,0 +1,10 @@ +from dotenv import load_dotenv +import os + + +load_dotenv() + +POSTGRES_DATABASE = os.environ.get("POSTGRES_DATABASE") +POSTGRES_USER = os.environ.get("POSTGRES_USER") +POSTGRES_PASSWORD = os.environ.get("POSTGRES_PASSWORD") +POSTGRES_HOST = os.environ.get("POSTGRES_HOST") diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..db31a91 --- /dev/null +++ b/src/models.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, String + +from .database import Base + + +class URL(Base): + __tablename__ = "url" + + key = Column(String, primary_key=True, nullable=False) + original_url = Column(String, nullable=False) diff --git a/src/routes/url/url_crud.py b/src/routes/url/url_crud.py new file mode 100644 index 0000000..0467d12 --- /dev/null +++ b/src/routes/url/url_crud.py @@ -0,0 +1,24 @@ +from sqlalchemy.orm import Session + +from ...models import URL + + +def storeKeyOfURL(db: Session, url: str, key: str): + url = URL( + key=key, + original_url=url + ) + db.add(url) + db.commit() + + return url + + +def getOriginalURL(db: Session, key: str): + url = db.query(URL).filter(URL.key == key).first() + return url + + +def getKeyOfURL(db: Session, url: str): + key = db.query(URL).filter(URL.original_url == url).first() + return key diff --git a/src/routes/url/url_route.py b/src/routes/url/url_route.py new file mode 100644 index 0000000..2b7eb2e --- /dev/null +++ b/src/routes/url/url_route.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session +from urllib.parse import urlparse +import random, string + +from ...database import get_db +from .url_schemas import URL, ShortenURLRes +from .url_crud import storeKeyOfURL, getOriginalURL, getKeyOfURL + + +routes = APIRouter( + tags=["url"] +) + + +@routes.post("/api/urls") +def shortenURL(url: URL, db: Session = Depends(get_db)) -> ShortenURLRes: + # check the URL is valid + try: + result = urlparse(url.url) + if not all([result.scheme, result.netloc]): + raise HTTPException(status_code=400, detail="INVALID_URL") + except ValueError: + raise HTTPException(status_code=400, detail="INVALID_URL") + + # get the URL of key from database + # if it is not found, generate a new key + # short url: https://host/key + db_key = getKeyOfURL(db, url=url.url) + key = None + if db_key is None: + letters = string.ascii_letters + letters += "".join(str(i) for i in range(1, 10)) + key = "".join(random.choice(letters) for i in range(6)) + storeKeyOfURL(db, url.url, key) + else: + key = db_key.key + + res = ShortenURLRes( + key=key, + original_url=url.url + ) + return res + + +@routes.get("/{key}") +def redirect(key: str, db: Session = Depends(get_db)): + # redirect to original URL if the key is exist + original_url = getOriginalURL(db, key) + if original_url is None: + return HTTPException(status_code=404, detail="URL_NOT_FOUND") + return RedirectResponse(original_url.original_url, status_code=301) diff --git a/src/routes/url/url_schemas.py b/src/routes/url/url_schemas.py new file mode 100644 index 0000000..5d1ff18 --- /dev/null +++ b/src/routes/url/url_schemas.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class URL(BaseModel): + url: str + + +class ShortenURLRes(BaseModel): + key: str + original_url: str