This commit is contained in:
sunwoo1524 2024-02-19 12:21:12 +09:00
commit df363e2297
17 changed files with 458 additions and 0 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
__pycache__/
venv/
postgres/

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
POSTGRES_HOST=db:5432
POSTGRES_DATABASE=postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=changeme!

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
venv/
__pycache__/
.env
docker-compose.yml
postgres/

9
Dockerfile Normal file
View file

@ -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

View file

@ -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

33
frontend/index.html Normal file
View file

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Krll URL Shortener</title>
<link rel="icon" href="favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="static/root.css">
<link rel="stylesheet" href="static/style.css">
</head>
<body>
<main>
<div class="input-container">
<input id="url" placeholder="Loooooooooooong URL" />
<button id="to-shorten-btn">Shorten</button>
</div>
<div class="result-container" id="result-container">
<p id="error">Your URL is invalid!</p>
<p id="short-url"></p>
<p id="copied">Copied!</p>
<img id="qr-code" />
</div>
</main>
<footer>
<p>스팸이나 불법적인 용도로 사용하지 말아주세요.</p>
<p>남용 신고 이메일 <a href="mailto:maengkkong1524@naver.com">maengkkong1524@naver.com</a></p>
</footer>
<script src="static/script.js"></script>
</body>
</html>

25
frontend/static/root.css Normal file
View file

@ -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);
}

97
frontend/static/script.js Normal file
View file

@ -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
}
}
}

90
frontend/static/style.css Normal file
View file

@ -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;
}

33
main.py Normal file
View file

@ -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")

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
fastapi[all]
gunicorn
sqlalchemy
psycopg2
python-dotenv

23
src/database.py Normal file
View file

@ -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()

10
src/env.py Normal file
View file

@ -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")

10
src/models.py Normal file
View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -0,0 +1,10 @@
from pydantic import BaseModel
class URL(BaseModel):
url: str
class ShortenURLRes(BaseModel):
key: str
original_url: str