v2
This commit is contained in:
commit
df363e2297
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
@ -0,0 +1,5 @@
|
|||
__pycache__/
|
||||
|
||||
venv/
|
||||
|
||||
postgres/
|
4
.env.example
Normal file
4
.env.example
Normal file
|
@ -0,0 +1,4 @@
|
|||
POSTGRES_HOST=db:5432
|
||||
POSTGRES_DATABASE=postgres
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=changeme!
|
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
venv/
|
||||
|
||||
__pycache__/
|
||||
|
||||
.env
|
||||
|
||||
docker-compose.yml
|
||||
|
||||
postgres/
|
9
Dockerfile
Normal file
9
Dockerfile
Normal 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
|
18
docker-compose.example.yml
Normal file
18
docker-compose.example.yml
Normal 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
33
frontend/index.html
Normal 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
25
frontend/static/root.css
Normal 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
97
frontend/static/script.js
Normal 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
90
frontend/static/style.css
Normal 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
33
main.py
Normal 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
5
requirements.txt
Normal file
|
@ -0,0 +1,5 @@
|
|||
fastapi[all]
|
||||
gunicorn
|
||||
sqlalchemy
|
||||
psycopg2
|
||||
python-dotenv
|
23
src/database.py
Normal file
23
src/database.py
Normal 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
10
src/env.py
Normal 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
10
src/models.py
Normal 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)
|
24
src/routes/url/url_crud.py
Normal file
24
src/routes/url/url_crud.py
Normal 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
|
53
src/routes/url/url_route.py
Normal file
53
src/routes/url/url_route.py
Normal 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)
|
10
src/routes/url/url_schemas.py
Normal file
10
src/routes/url/url_schemas.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class URL(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class ShortenURLRes(BaseModel):
|
||||
key: str
|
||||
original_url: str
|
Loading…
Reference in a new issue